The pinloop command line, version 0.3.0

This commit is contained in:
Andrew
2026-08-23 14:12:45 -04:00
commit 18025e0a10
26 changed files with 8879 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andrew Allen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+71
View File
@@ -0,0 +1,71 @@
# Pinloop CLI
This repository holds the source of the `pinloop` command, and nothing else.
It is the same code published to npm, so you can read what the program does on
your machine before you install it.
Pinloop is a job search tool built for your coding agent to run from a terminal.
It holds a large collection of frequently updated job postings, can hold your
resume and any other preferences, and calls AI models to evaluate postings
against what it knows about you.
## Install
Needs Node 22 or newer.
```
npm install -g pinloop
```
## Start
```
pinloop
```
Run on its own, `pinloop` prints instructions written for a coding agent.
`pinloop welcome` gets your coding agent to walk you through a more structured
onboarding flow, and `pinloop guide` gives it the full usage instructions.
## Accounts and payments
Run `pinloop login` to make an account.
The commands talk to Pinloop's servers, so everything except the guide needs an
account. Login goes through a browser, with no password.
A free account gets a limited number of judged postings and semantic searches per
month, and `pinloop upgrade` opens the page to raise those limits. Keyword
search, filtering, saved lists, stored documents, and routines cost nothing.
## What is here and what is not
Everything in this repository runs on your own machine. The program stores a
login token and your settings under your home directory, and makes HTTP calls to
Pinloop's servers for everything else.
The servers are a separate, closed codebase and are not in this repository. The
job postings, the profile you store, the AI judging, the accounts and the
payments all live there.
## Build it yourself
```
npm install
npm run build
node dist/cli/pinloop.js
```
## License
MIT. See [LICENSE](LICENSE).
The license covers this command line program. It is not permission to use the
Pinloop name, the servers, or the job postings the servers hold.
## Links
- Home: https://pinloop.ai
- Privacy: https://pinloop.ai/privacy
- Terms: https://pinloop.ai/terms
+27
View File
@@ -0,0 +1,27 @@
{
"name": "pinloop",
"version": "0.3.0",
"description": "The Pinloop command line: enable your coding agent to run your whole job search.",
"type": "module",
"bin": {
"pinloop": "./dist/cli/pinloop.js"
},
"homepage": "https://pinloop.ai",
"scripts": {
"build": "tsc"
},
"dependencies": {
"commander": "^15.0.0",
"picocolors": "^1.1.1",
"log-update": "^8.0.0",
"cli-spinners": "^3.4.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=22"
},
"license": "MIT"
}
+281
View File
@@ -0,0 +1,281 @@
/**
* The terminal half of signing in through a browser: the small web server
* `pinloop login` starts inside its own process, and the web address it prints.
*
* The feature is specs/feature-browser-sign-in.md, agreed 2026-08-19. In one
* paragraph. `pinloop login` starts listening on 127.0.0.1, which is the address
* a machine uses to talk to itself and which no other machine can reach. It
* listens on whatever port is free at that moment, and it invents a one-time
* secret. It then prints the web address of the sign-in page with that port and
* that secret written into it, and waits. A person opens that address in a
* browser and signs in with Google, with GitHub, or with a 6-digit code Pinloop
* emails them. The script inside the page then sends the pass to 127.0.0.1 on
* the port the terminal named, carrying the secret the terminal invented, and
* this file hands that pass back to the command, which saves it.
*
* Two things here are not obvious and are the point of the file.
*
* The secret. Without it, anything at all running on the machine could hand this
* terminal a pass, and a sign-in page still open in another window from an
* earlier `pinloop login` could hand a pass to the wrong terminal. A delivery
* carrying the wrong secret, or none, is refused and the terminal keeps waiting
* for the page it actually sent.
*
* The browser's question. A browser will not let a page at pinloop.ai send
* anything to 127.0.0.1 until it has asked the listener whether that is allowed.
* That question arrives as an OPTIONS request, before the delivery. If it goes
* unanswered the browser drops the delivery without a word, the terminal waits
* its whole ten minutes, and nothing anywhere says why. So it is answered.
*
* This file imports from src/cli and src/shared only, which is the rule
* src/cli/package-boundary.test.ts enforces: the published `pinloop` package is
* built out of those two folders and nothing else.
*/
import { spawn } from 'node:child_process';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import { platform } from 'node:process';
import {
GAVE_UP_LINE,
LOOPBACK_CALLBACK_PATH,
PORT_PARAMETER,
SECRET_PARAMETER,
SIGN_IN_PAGE_PATH,
SIGN_IN_SITE_URL,
SIGN_IN_SITE_URL_ENV_VAR,
WAIT_FOR_SIGN_IN_MS,
type DeliveredPass,
} from '../shared/sign-in.ts';
/** A terminal that is listening for a browser to hand it a pass. */
export type WaitingForSignIn = {
/** The whole web address to print: the sign-in page, the port and the secret. */
pageUrl: string;
/** The port the small web server is listening on, on 127.0.0.1. */
port: number;
/** The one-time secret this terminal invented. */
secret: string;
/**
* The pass the browser delivered. It fails, with GAVE_UP_LINE as its message,
* when nothing was delivered inside the wait.
*/
pass: Promise<DeliveredPass>;
/** Stops listening and drops the clock. Doing it twice is safe. */
stop(): Promise<void>;
};
export type WaitOptions = {
/**
* Where the sign-in page lives, without a trailing slash. Absent means the
* environment variable named in src/shared/sign-in.ts, and the hosted site
* when that is unset too.
*/
pageBaseUrl?: string;
/** How long to wait before giving up. Absent means WAIT_FOR_SIGN_IN_MS. */
giveUpAfterMs?: number;
};
/** Where the sign-in page lives for this run. */
export function signInPageBaseUrl(): string {
const chosen = process.env[SIGN_IN_SITE_URL_ENV_VAR];
const url = chosen && chosen.trim() !== '' ? chosen.trim() : SIGN_IN_SITE_URL;
return url.replace(/\/+$/, '');
}
/**
* How long a secret is: 32 characters of hexadecimal, which is 16 bytes of
* randomness out of the operating system's own source of it. Guessing one inside
* the ten minutes a terminal waits is not a thing that happens.
*/
const SECRET_BYTES = 16;
/** The largest delivery this listener will read, in bytes. */
const LARGEST_DELIVERY = 16 * 1024;
/** Whether two strings are the same, without taking longer for a closer guess. */
function sameSecret(mine: string, theirs: unknown): boolean {
if (typeof theirs !== 'string') return false;
const a = Buffer.from(mine, 'utf8');
const b = Buffer.from(theirs, 'utf8');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/** Reads a request body, keeping no more than LARGEST_DELIVERY of it. */
function readBody(request: IncomingMessage): Promise<string> {
return new Promise((done) => {
const chunks: Buffer[] = [];
let kept = 0;
request.on('data', (chunk: Buffer) => {
if (kept > LARGEST_DELIVERY) return;
kept += chunk.length;
chunks.push(Buffer.from(chunk));
});
request.on('end', () => done(Buffer.concat(chunks).toString('utf8')));
request.on('error', () => done(''));
});
}
/**
* The headers that tell the browser a page from somewhere else may talk to this
* listener.
*
* They say "any page", which sounds broader than it is: this listener answers
* only 127.0.0.1, it takes nothing but a delivery carrying the one-time secret
* it invented a moment ago, it hands nothing back but the word ok, and it stops
* listening the instant one arrives. Naming one site instead would mean this
* file having to know which site the person was sent to, and getting that wrong
* shows up as a sign-in that hangs with no message anywhere.
*/
function browserHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'content-type',
'Access-Control-Max-Age': '600',
};
}
function answer(reply: ServerResponse, status: number, body: Record<string, unknown>): void {
reply.writeHead(status, { 'content-type': 'application/json', ...browserHeaders() });
reply.end(JSON.stringify(body));
}
/**
* Starts listening on 127.0.0.1 and hands back the address to print, the port,
* the secret, and a promise of the pass.
*
* Nothing here opens a browser. Opening one is `openInBrowser` below, which the
* command calls separately, because the address has to be printed whether or not
* this machine has a browser at all: the person running the command is often a
* coding agent, and the human does the browser part on their own screen.
*/
export async function waitForBrowserSignIn(
options: WaitOptions = {},
): Promise<WaitingForSignIn> {
const secret = randomBytes(SECRET_BYTES).toString('hex');
const giveUpAfterMs = options.giveUpAfterMs ?? WAIT_FOR_SIGN_IN_MS;
const pageBaseUrl = (options.pageBaseUrl ?? signInPageBaseUrl()).replace(/\/+$/, '');
let handOver: (pass: DeliveredPass) => void = () => undefined;
let giveUpWith: (why: Error) => void = () => undefined;
const pass = new Promise<DeliveredPass>((delivered, failed) => {
handOver = delivered;
giveUpWith = failed;
});
// Nothing else may ever see this rejection as unhandled: the command attaches
// to it straight away, but a caller that stops early has not.
pass.catch(() => undefined);
const server: Server = createServer((request: IncomingMessage, reply: ServerResponse) => {
const path = (request.url ?? '/').split('?')[0];
// The browser's question, asked before it will send the delivery at all.
if (request.method === 'OPTIONS') {
reply.writeHead(204, browserHeaders());
reply.end();
return;
}
if (request.method !== 'POST' || path !== LOOPBACK_CALLBACK_PATH) {
answer(reply, 404, { error: 'this is the terminal waiting for a Pinloop sign-in' });
return;
}
void readBody(request).then((text) => {
let delivered: Partial<DeliveredPass> = {};
try {
delivered = JSON.parse(text) as Partial<DeliveredPass>;
} catch {
answer(reply, 400, { error: 'that was not a sign-in' });
return;
}
if (!sameSecret(secret, delivered.secret)) {
// Refused, and the wait goes on. This terminal is still waiting for the
// page it sent somebody to, and something else knocked.
answer(reply, 403, { error: 'this terminal did not send that sign-in' });
return;
}
if (typeof delivered.access_token !== 'string' || delivered.access_token === '') {
answer(reply, 400, { error: 'that sign-in carried no pass' });
return;
}
answer(reply, 200, { ok: true });
handOver({
secret,
access_token: delivered.access_token,
...(typeof delivered.refresh_token === 'string'
? { refresh_token: delivered.refresh_token }
: {}),
...(typeof delivered.email === 'string' ? { email: delivered.email } : {}),
});
});
});
await new Promise<void>((listening, failed) => {
server.once('error', failed);
server.listen(0, '127.0.0.1', () => listening());
});
const port = (server.address() as AddressInfo).port;
const clock = setTimeout(() => giveUpWith(new Error(GAVE_UP_LINE)), giveUpAfterMs);
let stopped = false;
const stop = async (): Promise<void> => {
if (stopped) return;
stopped = true;
clearTimeout(clock);
await new Promise<void>((closed) => server.close(() => closed()));
};
const pageUrl =
`${pageBaseUrl}${SIGN_IN_PAGE_PATH}` +
`?${PORT_PARAMETER}=${port}&${SECRET_PARAMETER}=${secret}`;
return { pageUrl, port, secret, pass, stop };
}
/**
* Opens one web address in this machine's browser, if this machine has one.
*
* Best effort and never required. It is started and forgotten: nothing waits for
* it, nothing reads what it printed, and a machine with no browser at all — a
* rented one reached over SSH, which is where the short code exists for — simply
* has nothing happen. The address is always printed as well, so the person can
* open it themselves wherever their browser actually is.
*/
export function openInBrowser(url: string): void {
const opener = browserOpener();
if (opener === undefined) return;
try {
const started = spawn(opener.command, [...opener.before, url], {
detached: true,
stdio: 'ignore',
});
// A machine where the opener is not installed raises an error on the object
// rather than throwing, and an unheard error event on a child process ends
// the whole program.
started.on('error', () => undefined);
started.unref();
} catch {
// Whatever went wrong, the address is printed and the person can open it.
}
}
/** The program that opens a web address on this machine, if there is one. */
function browserOpener(): { command: string; before: string[] } | undefined {
if (platform === 'darwin') return { command: 'open', before: [] };
if (platform === 'win32') return { command: 'cmd', before: ['/c', 'start', ''] };
// On Linux, a machine with no screen attached has no browser to open a page
// in, and every terminal reached over SSH is such a machine. Asking whether a
// screen exists is what keeps this from starting a program that cannot
// possibly do anything on exactly the machines the short code exists for.
const hasAScreen =
(process.env['DISPLAY'] ?? '') !== '' || (process.env['WAYLAND_DISPLAY'] ?? '') !== '';
if (!hasAScreen) return undefined;
return { command: 'xdg-open', before: [] };
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Turning what the server sent into the words a person reads at a terminal.
*
* Only the command line prints for people. The server hands back whole numbers
* and stored fields, and the command line writes them as a sentence, so these
* functions live beside the command line rather than beside the counting. The
* counting itself, and the shape the two numbers travel in, is
* src/shared/coverage.ts, and the rule about always leading with the fraction is
* written out in full there.
*
* Nothing in this file reads a file, opens a connection or calls anything. It
* takes numbers and text and gives back text.
*/
import type { Coverage } from '../shared/coverage.ts';
/** One whole number with thousands separators, for a person to read. */
export function withSeparators(count: number): string {
return count.toLocaleString('en-US');
}
/**
* The fraction a person reads, real numbers and never reduced: "996/1,000".
* A whole of nothing reads "0/0", because there was nothing to cover.
*/
export function fractionOf(covered: number, total: number): string {
return `${withSeparators(covered)}/${withSeparators(total)}`;
}
/** The same fraction, taken straight off a coverage the answer carried. */
export function fractionIn(held: Coverage): string {
return fractionOf(held.covered, held.total);
}
/**
* One posting named the way a person recognises it: the job title, then the
* employer, then the posting id in parentheses.
*
* A message that named a posting by its id alone — "could not judge
* jb-88213" — left the person reading it with a string they never typed and
* cannot place, so they had to go and fetch the posting to find out which job
* the message was even about. The title and the employer are what answer that,
* and the id stays on the end because it is the thing that gets pasted into
* `pinloop fetch` or `pinloop judgment get` afterwards.
*
* A posting the server sent no title for is named by its employer alone, and one
* with neither a title nor an employer falls back to the bare id, because "the
* posting at " with nothing after it is worse than the id it replaced.
*/
export function postingNamed(
title: string | null | undefined,
company: string | null | undefined,
id: string,
): string {
const job = String(title ?? '').trim();
const employer = String(company ?? '').trim();
if (job !== '' && employer !== '') return `the ${job} posting at ${employer} (${id})`;
if (job !== '') return `the ${job} posting (${id})`;
if (employer !== '') return `the posting at ${employer} (${id})`;
return id;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* STUB. The CLI does not exist yet.
*
* The names below are not implementation — they are the contract the CLI tests
* pin: which environment variables point the CLI at a server and at a config
* directory, and what the saved-login file is called inside that directory.
*/
/**
* Environment variable holding the base URL of the Pinloop server the CLI talks
* to. The tests set it to the locally started server.
*/
export const SERVER_URL_ENV_VAR = 'PINLOOP_SERVER_URL';
/**
* The hosted server every installed copy of this command talks to when the
* environment variable above is not set. This is compiled into every published
* copy, so a copy already installed on somebody's machine keeps using whatever
* address was in it at install time. Changing it therefore strands every
* installed copy unless the old address keeps answering, which is why the value
* is pinned by a test rather than left as a string anyone can edit quietly.
*/
export const DEFAULT_SERVER_URL = 'https://api.pinloop.ai';
/**
* Environment variable overriding where the CLI keeps its config. Without it the
* CLI uses the usual per-user location; the tests point it at a throwaway
* directory so a test run never touches Andrew's real login.
*/
export const CONFIG_DIR_ENV_VAR = 'PINLOOP_CONFIG_DIR';
/**
* The file inside the config directory that holds the signed pass after a
* successful login. Owner-read/write only, like an ssh private key.
*/
export const CREDENTIALS_FILENAME = 'credentials.json';
/** The file mode the saved-login file must carry: owner read+write, nothing else. */
export const CREDENTIALS_FILE_MODE = 0o600;
+4728
View File
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
/**
* One row of a list, printed the way a person reads it
* (specs/feature-terminal-polish.md, AGREED 2026-08-19, the section headed
* "Aligned rows with colour when a command answers").
*
* Every function here takes a stored row, the colour functions to build it with,
* and how wide the window is, and hands back the text of that row. None of them
* writes anything anywhere and none of them reaches for a colour of its own.
* That is what makes the same code produce coloured rows for a person and plain
* rows for a pipe: the caller hands in colour functions that colour nothing when
* nobody is watching a screen.
*
* The shape one posting prints as:
*
* Software Engineer Intern Joby Aviation Santa Cruz, CA 2026-08-12
* 7f732227 https://jobs.jobvite.com/joby/job/abc
*
* Three spaces between the columns of the first row, which is wide enough to
* read as a break and narrow enough that a job title and an employer stay on one
* row. The second row is indented by two spaces and carries the posting's id and
* its web address, both of which a person copies rather than reads, so they are
* out of the way of the reading and still easy to select. The job title is the
* one thing in bold; everything else on both rows is dim.
*
* Nothing here is ever cut short. Only the group of lines that is redrawn in
* place is cut (src/cli/screen.ts), because erasing that group works by counting
* the rows it drew and a row the terminal wrapped makes the count wrong. A
* printed row is written once and never erased, so a terminal wrapping it costs
* nothing, and cutting a job title in half would cost a person the thing they
* were reading.
*/
import type { Colours } from './screen.ts';
/** How many characters sit between two columns of the same row. */
const BETWEEN_COLUMNS = ' ';
/** How far the second row of a posting is indented. */
const INDENT = ' ';
/**
* How many characters sit between the id and the web address on that second
* row. Two rather than three, because both are things a person double-clicks
* rather than reads, and they read as one pair.
*/
const BETWEEN_ID_AND_LINK = ' ';
/** How much of an id is printed where the whole thing is not needed. */
const SHORT_ID = 8;
/** One posting, as a search, a list, a tab and a routine run all print it. */
export type PostingRow = {
id: string;
title?: string | null;
company?: string | null;
locations?: string[] | null;
posted_at?: string | null;
url?: string | null;
};
/** One employer, as `pinloop companies` prints it. */
export type CompanyRow = {
id?: string | null;
name?: string | null;
website_domain?: string | null;
posting_count?: number | null;
};
/** One tab, as `pinloop tab list` prints it. */
export type TabListRow = {
name?: string | null;
items?: number | null;
description?: string | null;
};
/** One stored verdict, as judge and `pinloop judgment list` print it. */
export type VerdictRow = {
id?: string | null;
verdict?: string | null;
judged_at?: string | null;
judged_by?: string | null;
};
/** The parts of a row that carried something, joined into one row. */
function joined(parts: (string | undefined)[]): string {
return parts.filter((one): one is string => one !== undefined && one !== '').join(BETWEEN_COLUMNS);
}
/** One value as text, or nothing at all when the row carries nothing for it. */
function said(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
const text = String(value);
return text === '' ? undefined : text;
}
/** The day a date fell on, which is all of a stored instant a person reads. */
function day(value: unknown): string | undefined {
const text = said(value);
return text === undefined ? undefined : text.slice(0, 10);
}
/**
* One posting as two rows: what it is, and then how to reach it.
*
* A column the posting carries nothing for is left out rather than printed
* empty, so a posting with no location and no date reads as a title and an
* employer rather than as a row of gaps.
*/
export function postingRow(row: PostingRow, colours: Colours, _columns: number): string {
const facts = joined([said(row.company), said(row.locations?.[0]), day(row.posted_at)]);
const first = joined([colours.bold(said(row.title) ?? ''), facts === '' ? undefined : colours.dim(facts)]);
const reach = [said(row.id)?.slice(0, SHORT_ID), said(row.url)]
.filter((one): one is string => one !== undefined && one !== '')
.join(BETWEEN_ID_AND_LINK);
return `${first}\n${INDENT}${colours.dim(reach)}`;
}
/**
* One employer as two rows, with its whole id on the second.
*
* The whole id and not the first eight characters, because handing that id to
* `pinloop search --company` is the only reason this list exists. Employer names
* in the corpus are messy — "Google" and "Google LLC" are two separate rows —
* so a person picks the right employer by its name and its posting count and
* then copies the id underneath it.
*/
export function companyRow(row: CompanyRow, colours: Colours, _columns: number): string {
const count = Number(row.posting_count ?? 0);
const facts = joined([
said(row.website_domain),
`${count} posting${count === 1 ? '' : 's'}`,
]);
const first = joined([colours.bold(said(row.name) ?? ''), colours.dim(facts)]);
return `${first}\n${INDENT}${colours.dim(said(row.id) ?? '')}`;
}
/** One tab as a single row: its name, how much it holds, and what it is for. */
export function tabRow(row: TabListRow, colours: Colours, _columns: number): string {
const items = Number(row.items ?? 0);
const facts = joined([
`${items} posting${items === 1 ? '' : 's'}`,
said(row.description),
]);
return joined([colours.bold(said(row.name) ?? ''), colours.dim(facts)]);
}
/**
* The colour each of the four verdict words is written in, worst to best: red
* for a no, dim for a weak, yellow for a fair, green for a strong. A word
* outside the four is left uncoloured rather than guessed at.
*/
function inItsOwnColour(verdict: string, colours: Colours): string {
if (verdict === 'no') return colours.red(verdict);
if (verdict === 'weak') return colours.dim(verdict);
if (verdict === 'fair') return colours.yellow(verdict);
if (verdict === 'strong') return colours.green(verdict);
return verdict;
}
/**
* One stored verdict as a single row: the word, then the posting it is about,
* when it was made and who made it.
*
* Who judged is on every row because a verdict Pinloop bought and one a person
* stored by hand are otherwise identical to look at, and telling them apart at a
* glance is the whole point of the column.
*/
export function verdictRow(row: VerdictRow, colours: Colours, _columns: number): string {
const word = said(row.verdict) ?? '';
const rest = joined([
said(row.id),
day(row.judged_at),
said(row.judged_by) === undefined ? undefined : `judged by ${said(row.judged_by)}`,
]);
return `${inItsOwnColour(word, colours)}${BETWEEN_COLUMNS}${colours.dim(rest)}`;
}
/**
* The one line a list ends with, saying how much of what matched came back.
*
* The words are the caller's and are not touched here. All this does is dim
* them, so that the line reads as a note about the list rather than as another
* row of it.
*/
export function summaryLine(line: string, colours: Colours): string {
return colours.dim(line);
}
+828
View File
@@ -0,0 +1,828 @@
/**
* The one piece of code inside the `pinloop` command that writes to the screen
* while a command is still working (specs/feature-terminal-polish.md, AGREED
* 2026-08-19).
*
* Everything a command says WHILE it works goes through here, and every one of
* those writes goes to standard error. Standard output is left alone entirely,
* whoever is watching, because standard output is what a shell pipe reads and
* what the next command in a pipeline parses. The answer itself is printed by
* the command's own code, after the work has finished, exactly as it was printed
* before this file existed.
*
* ---------------------------------------------------------------------------
* Two ways of printing, decided once
* ---------------------------------------------------------------------------
*
* As the command starts it asks the operating system one question: is standard
* error attached to a terminal window? A terminal window means a person is
* reading, and the command prints for a person. A pipe, a file or a coding
* agent's captured output means a program is reading, and the command prints for
* a coding agent. Typing `--plain` forces the second way inside a terminal
* window.
*
* Printing for a person means a small group of lines at the bottom of the
* screen that is erased and drawn again in place, so nothing scrolls past:
*
* judging 12 postings, 4 at a time, anthropic/claude-sonnet-5
* o 7f732227 thinking: comparing the Paris role against the location
* o a91c0d3e answer 1,240 chars
* v 5 of 12 done x 0 failed 0:48
*
* A quick screen, which is `pinloop judge --quick`, gets that same group of
* lines with the count of finished postings left off the last one, so the last
* line reads `x 0 failed 0:48`. One quick call carries up to a hundred
* postings, so that count cannot move until the call comes back (Andrew,
* 2026-08-20).
*
* and, for every other command, one line carrying the same turning character,
* what it is doing and a clock:
*
* o searching... 0:04
*
* A command that has to say more than fits after the clock draws a second row
* underneath the waiting line, indented by two spaces, with no turning
* character on it:
*
* o listing... page 27, 2700 postings so far
* over the per-minute limit, asking for the same page again in 5s
*
* The first row keeps what the run has got to, and the second row carries the
* explanation. A `pinloop list --all` run that goes over the account's
* allowance used to put the whole explanation after the clock, where it was cut
* off part way through a word and where it took the page count off the screen,
* so the one thing showing that the run was still moving disappeared (Andrew,
* 2026-08-19).
*
* A second row that names a number of seconds still to wait counts that number
* down, one a second, until the wait is over: 5s, then 4s, then 3s, then 2s,
* then 1s. It never shows 0s and never shows a fraction. Andrew watched a run
* at his own terminal on 2026-08-20 where the row said "in 5s", five seconds
* went by, and the row still said "in 5s", so there was no way to tell a
* command that was about to ask again from one that had stopped for good.
*
* The letter o at the front of a running call's row and at the front of the
* waiting line stands in for a character that turns. The real characters are the
* ten braille dot patterns of the `cli-spinners` package's `dots` animation, and
* a new one of them is shown every 80 milliseconds, so a call the model has gone
* quiet on and a command that is only waiting both visibly move (Andrew,
* 2026-08-19, after watching a run where nothing on the screen moved at all).
* The letters v and x stand in for the tick and the cross on the row counting
* what is done. All three are written as letters here only so that this comment
* stays plain text; the code below uses the real characters.
*
* Printing for a coding agent means a short list of plain lines and nothing
* else. Nothing is ever erased, because a pipe and a file only accept more text,
* and none of the model's thinking is written at all: a coding agent cannot do
* anything with the thinking and has to store every line it is handed.
*
* judging 12 postings with anthropic/claude-sonnet-5 via Anthropic
* verdict for 7f732227: no (5 of 12 done)
* call 1 of 3 done, 100 verdicts (100 of 300 done)
* call 2 of 3 failed: the model provider closed the connection
*
* ---------------------------------------------------------------------------
* Why a row is cut rather than wrapped
* ---------------------------------------------------------------------------
*
* The group of lines is erased by counting the rows it drew and moving the
* cursor back up that many rows. A row longer than the window is carried onto a
* second screen row by the terminal itself, which makes that count wrong and
* leaves half of the old group on the screen. So every row is cut to fit and
* ended with the single ellipsis character. Rows are cut one character short of
* the window's width, because a terminal that has just had its very last column
* filled is already sitting on the row below.
*
* The row showing a model's thinking is the one row cut the other way round. The
* provider sends the thinking a few characters at a time; this file joins those
* pieces together and the row shows the END of the joined text, with the
* ellipsis character at the front where the earlier words were left out. The
* words then slide leftwards off the row as new ones arrive, so the row always
* reads as a run of sentences rather than as whichever fragment arrived last
* (Andrew, 2026-08-19, after watching a real run).
*/
import spinners from 'cli-spinners';
import { createLogUpdate } from 'log-update';
import { createColors } from 'picocolors';
import { withSeparators } from './format.ts';
import { MAX_IN_FLIGHT_REQUESTS } from '../shared/model-calls.ts';
import type { ProgressEvent } from '../shared/progress-events.ts';
/**
* The functions that wrap a piece of text in the codes that colour it.
*
* They are handed to everything that builds a row rather than reached for, so
* that the same row-building code produces coloured text for a person and
* untouched text for a pipe, and so that a test can hand in functions that mark
* their text with words instead of with invisible codes.
*/
export type Colours = {
bold: (text: string) => string;
dim: (text: string) => string;
red: (text: string) => string;
yellow: (text: string) => string;
green: (text: string) => string;
};
/** Which of the two ways of printing this run picked. */
export type ScreenMode = 'person' | 'program';
/** What one screen is told about the terminal it is writing to. */
export type ScreenOptions = {
/** Whether standard error is attached to a terminal window. */
stderrIsTTY: boolean;
/** Whether standard output is attached to a terminal window. */
stdoutIsTTY: boolean;
/** Whether the person typed --plain. */
plain: boolean;
/** Whether the environment variable NO_COLOR is set to anything. */
noColor: boolean;
/** How many characters wide the terminal window is. */
columns: number;
/** The stream every write goes to. */
stderr: NodeJS.WritableStream;
/** Standard output, which this file never writes a byte to. */
stdout: NodeJS.WritableStream;
/** The clock, so that a test can move time by hand. */
now?: () => number;
};
/** The screen a command draws on while it works. */
export type Screen = {
/** Which of the two ways of printing this run picked. */
readonly mode: ScreenMode;
/** The colour functions every printed row is built with. */
readonly colours: Colours;
/**
* This command is now waiting on the server, doing the thing this label
* names. Calling it again while a wait is already going changes the label
* without restarting the clock, which is how a routine run says which step it
* has reached. Waits nest: two calls need two stops.
*/
wait(label: string): void;
/**
* The same wait, doing something else now. It changes the words on the line
* without opening a second wait and without restarting the clock, which is how
* a routine run says which of its steps it has reached.
*/
relabel(label: string): void;
/** What the waiting command has got to, in place of the clock. */
update(detail: string): void;
/**
* A second row of text drawn underneath the waiting line, for an explanation
* too long to sit after the clock. The row is indented by two spaces and
* carries no turning character, and calling this with nothing takes the row
* off again. What the waiting command has got to stays on the first row
* either way, so the explanation never hides it.
*/
note(saying: string | undefined): void;
/**
* The same second row, carrying a number of seconds that falls as a wait runs
* out.
*
* `milliseconds` is how long the wait has left to run from this moment, and
* `wording` is asked for the sentence to draw every time the row is redrawn,
* with the whole seconds still to wait handed to it. The number is rounded
* up and never goes below 1, so the row never reads "in 0s" and never reads a
* fraction of a second. The words themselves stay with the command that is
* waiting; all this does is redraw them as the number changes.
*
* The row comes off the same way any other second row does: `note` with a
* plain sentence replaces it, and `note` with nothing takes it away.
*/
countdown(milliseconds: number, wording: (secondsLeft: number) => string): void;
/** This wait is over. */
stop(): void;
/**
* Whether the judge run that is about to start is a quick screen, which is
* `pinloop judge --quick`: up to a hundred postings sent in one model call
* rather than one call for each posting.
*
* The judge command says which kind of run it is before the run starts.
* Nothing here works it out from the messages the server sends, because the
* messages of a quick screen carrying a hundred postings and the messages of
* an ordinary run of a hundred postings say the same things.
*
* The one thing it changes is the last row of the group drawn while the run
* goes. On a quick screen that row leaves out the count of finished postings;
* see `judgeRows` further down for why.
*/
judgeIsQuick(quick: boolean): void;
/** One thing that happened while a judge run was going. */
judge(event: ProgressEvent): void;
/** The whole command is done: erase everything and give the cursor back. */
finish(): void;
/** Make the cursor visible again, right now. */
restoreCursor(): void;
};
/** The bytes that make the cursor visible again. */
const SHOW_CURSOR = '\u001b[?25h';
/** How long a command waits before it draws a waiting line at all. */
const QUIET_FIRST_MS = 1_000;
/** The shortest gap between two redraws, so the screen does not flicker. */
const REDRAW_EVERY_MS = 100;
/**
* How tall the screen is taken to be when the terminal will not say. A terminal
* that reports no height, or a height of zero, would otherwise have everything
* clipped away and nothing drawn.
*/
const ASSUMED_HEIGHT = 24;
/** Below this many characters wide, the group shrinks to its last row alone. */
const NARROW_WINDOW = 40;
/**
* The characters that turn at the front of the waiting line and at the front of
* every row of a call that is still running.
*
* The `cli-spinners` package is a plain list of animations with no code in it
* and no packages of its own. The one taken here is `dots`: ten braille
* characters with a different pattern of dots raised in each, shown one after
* another 80 milliseconds apart, which reads as a small shape turning on the
* spot. Andrew watched the built command run on 2026-08-19 and asked for this:
* until then the waiting line was a word and a clock that changed once a second
* and stood still the rest of the time, and the row of a call the model had gone
* quiet on did not change at all, so a person could not tell a slow run from a
* stuck one.
*/
const TURNING = spinners.dots;
/**
* The character to show at the front of a moving row after this many
* milliseconds of waiting or of judging.
*
* The character is worked out from elapsed time rather than counted up on each
* redraw. The redraw happens ten times a second and the animation wants a new
* character every 80 milliseconds, so the two do not line up; reading the clock
* keeps the animation at its own speed whatever the redraw does, and it means a
* test that moves the clock by hand always gets the same character back.
*/
function turningAt(milliseconds: number): string {
const step = Math.floor(Math.max(0, milliseconds) / TURNING.interval);
return TURNING.frames[step % TURNING.frames.length] ?? '';
}
/** The marks on the row counting what is done and what could not be done. */
const DONE_MARK = '✓';
const FAILED_MARK = '✗';
/**
* The one character a row that had to be cut short ends with, and the one that
* separates the waiting line's label from its clock. Three full stops in a row
* would take three columns of a window this code is already fighting for.
*/
const CUT_MARK = '…';
/** What a call's row says before the model has sent anything. */
const NOTHING_YET = 'starting';
/**
* How many characters of one call's thinking are kept.
*
* The provider sends the model's thinking a few characters at a time, and this
* file joins those pieces together so the row reads as sentences rather than as
* whatever fragment arrived last. A model can think for minutes, so the joined
* text would grow to hundreds of thousands of characters if nothing threw any
* of it away. Only the end of it is ever shown, and a hundred-column window
* shows about eighty characters, so keeping the last few thousand is far more
* than any row can hold and still costs almost nothing to hold in memory.
*/
const THINKING_KEPT = 4_000;
/**
* The model's thinking with every run of spaces, tabs and newlines turned into
* one single space, and any space at the very front taken off.
*
* A model that writes in paragraphs sends newlines, and a newline inside a row
* would push the rest of the group onto extra screen rows, which makes the
* count of rows to erase wrong and leaves half of the old group behind.
*/
function oneLine(text: string): string {
return text.replace(/\s+/g, ' ').replace(/^ /, '');
}
/** A count of minutes and seconds, as a person reads a stopwatch. */
function clockText(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
}
/**
* One posting named the way every row here names it: the first eight characters
* of its id.
*
* A server that is older than this command sends no posting on the message that
* opens a call, because that field was added with this feature. The row then
* names nothing rather than printing the word "undefined" across the screen.
*/
function shortId(id: string | undefined): string {
if (id === undefined || id === null || String(id) === '') return '';
return String(id).slice(0, 8);
}
/** "1 posting" or "12 postings". */
function postingWord(count: number): string {
return `${count} posting${count === 1 ? '' : 's'}`;
}
/**
* What one open call is doing, kept so its row can be drawn again on the next
* redraw without waiting for the model to say something new.
*/
type OpenCall = {
/** The posting this call is about, or the first of the many it carries. */
id: string;
/** How many postings this one call carries. */
postings: number;
/** How many calls the whole run makes. */
of: number;
/** The last thing that happened on this call, already written out as a row. */
latest: string;
/**
* Everything the model has thought on this call so far, with the pieces it
* arrived in joined together and every run of whitespace turned into one
* space. Empty when the model has sent no thinking, or when an answer has
* arrived and taken the row over.
*/
thinking: string;
/** How many verdicts this call has produced so far. */
verdicts: number;
};
export function openScreen(options: ScreenOptions): Screen {
const mode: ScreenMode = options.stderrIsTTY && !options.plain ? 'person' : 'program';
// Colour is decided by standard output rather than by standard error, because
// colour goes on the answer and the answer goes to standard output. The two
// streams can point at different places: `pinloop search x | less` leaves the
// waiting line on the screen and hands `less` plain text.
const colours = createColors(
options.stdoutIsTTY && !options.noColor && !options.plain,
) as unknown as Colours;
const now = options.now ?? ((): number => Date.now());
const say = (line: string): void => {
options.stderr.write(`${line}\n`);
};
// -------------------------------------------------------------------------
// What a judge run has said so far. Both ways of printing keep the same
// record: one entry per call that is still running, plus the counts.
// -------------------------------------------------------------------------
/** The calls still running, in the order they started. */
const open = new Map<number, OpenCall>();
/** How many postings the whole run covers, worked out from the first call. */
let runPostings = 0;
/** The model that really answered, as the provider named it. */
let runModel = '';
/** True once the line naming the run has been printed for a coding agent. */
let namedTheRun = false;
/** Postings finished, and calls that could not be finished. */
let done = 0;
let failedCalls = 0;
/**
* True when this run is a quick screen rather than an ordinary judge run, as
* the judge command said before the run started.
*/
let quickRun = false;
/**
* How many postings the whole run covers, read off the first call that
* started. An ordinary run makes one call per posting; a screening run makes
* one call per hundred, and says so on every call it starts.
*/
const wholeRun = (event: { of: number; postings: number }): number =>
Math.max(0, event.of) * Math.max(1, event.postings);
// -------------------------------------------------------------------------
// Printing for a coding agent
// -------------------------------------------------------------------------
if (mode === 'program') {
/** The line one screening call prints once it has produced its verdicts. */
const callDone = (call: number, one: OpenCall): void => {
say(
`call ${call} of ${one.of} done, ${one.verdicts} verdicts ` +
`(${done} of ${runPostings} done)`,
);
one.verdicts = 0;
};
return {
mode,
colours,
wait: () => undefined,
relabel: () => undefined,
update: () => undefined,
note: () => undefined,
countdown: () => undefined,
stop: () => undefined,
// A coding agent's lines are the same lines whichever kind of run this
// is, so which kind it is changes nothing here.
judgeIsQuick: () => undefined,
restoreCursor: () => undefined,
judge(event: ProgressEvent): void {
if (event.kind === 'call-started') {
open.set(event.call, {
id: event.id,
postings: event.postings,
of: event.of,
latest: NOTHING_YET,
thinking: '',
verdicts: 0,
});
if (namedTheRun) return;
namedTheRun = true;
runPostings = wholeRun(event);
// The model and the company serving it are read off the first piece
// the provider sent back rather than out of anything written down
// here, because the model that answers is not always the model that
// was asked for.
say(`judging ${postingWord(runPostings)} with ${event.model} via ${event.provider}`);
return;
}
if (event.kind === 'verdict') {
done += 1;
const one = open.get(event.call);
// A call carrying one posting names that posting. A screening call
// carries a hundred, and naming them all would be a hundred lines a
// coding agent has to store, so it says how many verdicts it produced
// instead, once, when it has produced all of them.
if (one === undefined || one.postings <= 1) {
say(
`verdict for ${shortId(event.id)}: ${event.verdict} ` +
`(${done} of ${runPostings} done)`,
);
return;
}
one.verdicts += 1;
if (one.verdicts >= one.postings) callDone(event.call, one);
return;
}
if (event.kind === 'call-failed') {
say(`call ${event.call} of ${event.of} failed: ${event.reason}`);
open.delete(event.call);
}
// The model's thinking, the running count of answer characters, and the
// line saying a step of a routine has started are all left out. None of
// them is something a coding agent can act on, and every one of them
// would be a line it has to store.
},
finish(): void {
// A screening call that answered for fewer postings than it carried
// never reached the count that prints its line, so it is printed here
// with the number of verdicts it really produced.
for (const [call, one] of open) {
if (one.postings > 1 && one.verdicts > 0) callDone(call, one);
}
open.clear();
},
};
}
// -------------------------------------------------------------------------
// Printing for a person
// -------------------------------------------------------------------------
const room = Math.max(1, options.columns - 1);
// The redrawing library clips what it draws to the height the terminal
// reports, and a terminal that reports a height of zero gets nothing drawn at
// all. Some terminals really do report zero: one handed out by the `script`
// program without a window size is one, and a person in that terminal would
// watch a judge run in total silence with nothing failing anywhere. So the
// stream handed to the library reports a sensible height whenever the real
// one is zero or missing, and reports the real height otherwise. Everything
// else about the stream, including every write, is the real stream's.
const drawnOn = new Proxy(options.stderr as NodeJS.WriteStream, {
get(real, name, receiver) {
if (name === 'rows') {
const height = (real as NodeJS.WriteStream).rows;
return height === undefined || height <= 0 ? ASSUMED_HEIGHT : height;
}
const value = Reflect.get(real, name, receiver);
return typeof value === 'function' ? value.bind(real) : value;
},
});
const draw = createLogUpdate(drawnOn, {
defaultWidth: options.columns,
defaultHeight: ASSUMED_HEIGHT,
});
/** True while anything at all is on the screen. */
let drawn = false;
/**
* True once anything has been drawn at all, ever. The cursor is hidden by the
* first thing drawn and not before, so a command that answered too quickly to
* draw anything must not write the code that shows the cursor again either:
* that code would be the last thing on the screen, sitting after the answer.
*/
let everDrawn = false;
/** How many waits are open; the line comes off when the last one ends. */
let waits = 0;
/** What the waiting command is doing, and what it has got to. */
let label = '';
let detail: string | undefined;
/**
* What the second row under the waiting line says, or nothing when there is
* no second row. It holds an explanation too long to sit after the clock, and
* it is kept apart from the detail above so that setting one never takes the
* other off the screen.
*
* It is held as something that is asked afresh on every redraw rather than as
* a finished sentence, because a row that counts seconds down has to be built
* again each time the clock has moved. A row set by `note` is a sentence that
* never changes, and is held here as something that hands back that same
* sentence every time.
*/
let underneath: (() => string) | undefined;
/** When the first of the open waits began, which the clock counts from. */
let waitStartedAt = 0;
/** When the first thing happened in a judge run, which its clock counts from. */
let judgeStartedAt: number | undefined;
/** When the screen was last written to, so it is redrawn ten times a second. */
let lastDrawnAt = Number.NEGATIVE_INFINITY;
let redrawing: NodeJS.Timeout | undefined;
/** One row, cut to fit the window and ended with one ellipsis character. */
const fit = (line: string): string =>
line.length <= room ? line : `${line.slice(0, Math.max(0, room - 1))}${CUT_MARK}`;
/**
* The last however-many characters of a piece of text that fit in the space
* given, with one ellipsis character at the FRONT when the beginning had to be
* left out.
*
* This is how the model's thinking is cut, and it is cut the other way round
* from every other row here. The newest words of the thinking are the ones a
* person wants to read, so the row keeps the end of the text and drops the
* beginning, and the words slide leftwards off the row as new ones arrive.
* Every other row keeps its beginning and drops its end, which is what `fit`
* above does.
*/
const lastPartThatFits = (text: string, space: number): string => {
if (space <= 0) return '';
if (text.length <= space) return text;
if (space === 1) return CUT_MARK;
return `${CUT_MARK}${text.slice(text.length - (space - 1))}`;
};
/** The rows of the group drawn while a judge run goes. */
const judgeRows = (): string[] => {
const sinceTheRunBegan = now() - (judgeStartedAt ?? now());
const running = clockText(sinceTheRunBegan);
// A quick screen sends up to a hundred postings in one model call, so the
// count of finished postings sits at 0 for the whole run and then jumps
// straight to the total when that one call comes back. Andrew ran a quick
// screen of thirty postings at his terminal on 2026-08-20 and watched it do
// that, so the count is left off a quick screen's row altogether and the row
// carries what does move: the calls that failed and the clock. An ordinary
// judge run makes one call per posting, where the count rises as the run
// goes, and its row is unchanged.
const counted = quickRun
? `${FAILED_MARK} ${failedCalls} failed ${running}`
: `${DONE_MARK} ${done} of ${runPostings} done ` +
`${FAILED_MARK} ${failedCalls} failed ${running}`;
// A window too narrow for a row of columns gets the one row that matters.
if (options.columns < NARROW_WINDOW) return [fit(counted)];
const naming =
`judging ${postingWord(runPostings)}, ${MAX_IN_FLIGHT_REQUESTS} at a time, ${runModel}`;
const calls = [...open.values()]
.slice(0, MAX_IN_FLIGHT_REQUESTS)
.map((one) => {
const named = shortId(one.id);
// The row of a running call starts with the turning character, so a call
// the model is saying nothing on still moves. The tick and the cross on
// the row below are marks of something that has already happened, so
// they stay the fixed characters they are.
const head = `${turningAt(sinceTheRunBegan)} ${named === '' ? '' : `${named} `}`;
const thought = one.thinking.trimEnd();
if (thought === '') return `${head}${one.latest}`;
// The turning character, the posting and the word "thinking:" always
// stay on the row; what is left of the window's width is filled with the
// end of what the model has thought so far.
const said = `${head}thinking: `;
return `${said}${lastPartThatFits(thought, room - said.length)}`;
});
return [naming, ...calls, counted].map(fit);
};
/** What should be on the screen right now, as rows. */
const frame = (): string[] => {
if (judgeStartedAt !== undefined) return judgeRows();
if (waits === 0) return [];
const waited = now() - waitStartedAt;
// A command that answers quickly leaves no trace of having waited at all.
if (waited < QUIET_FIRST_MS) return [];
const waiting = fit(`${turningAt(waited)} ${label}${CUT_MARK} ${detail ?? clockText(waited)}`);
const explanation = underneath?.();
if (explanation === undefined) return [waiting];
// The second row is indented by two spaces so a person reads it as
// belonging to the line above it. It is cut to the window by the same rule
// as every other row, because the group is erased by counting the rows it
// drew, and a row too long for the window is carried onto a second screen
// row by the terminal itself, which makes that count wrong.
return [waiting, fit(` ${explanation}`)];
};
/**
* How many whole seconds a wait that ends at the given moment still has to
* run, as the row underneath the waiting line says it.
*
* The number is rounded up, so a wait with two and a half seconds left reads
* 3s, and it never falls below 1: while there is any time left at all the row
* reads 1s rather than 0s, and the command that is waiting replaces the row
* with the words saying its request has gone out again the moment the wait
* ends.
*/
const secondsLeftUntil = (endsAt: number): number =>
Math.max(1, Math.ceil((endsAt - now()) / 1_000));
/**
* Puts what should be on the screen on the screen.
*
* `insist` is for the two moments where the screen has to be right this
* instant rather than at the next redraw: a wait ending, and the whole command
* finishing. Everything else is held to ten redraws a second, because a judge
* run produces a piece of the model's thinking every few milliseconds and
* redrawing on every one of them makes the screen flicker.
*/
const render = (insist = false): void => {
if (!insist && now() - lastDrawnAt < REDRAW_EVERY_MS) return;
const rows = frame();
if (rows.length === 0) {
if (drawn) {
draw.clear();
drawn = false;
}
return;
}
lastDrawnAt = now();
draw(rows.join('\n'));
drawn = true;
everDrawn = true;
};
/**
* Keeps the clock and the turning character on the screen moving even when
* nothing else happens.
*
* This redraw insists, where a redraw set off by something the model sent does
* not. The gap between two of these redraws and the gap the throttle above
* asks for are both a tenth of a second, and a timer that fires a fraction of
* a millisecond early would be turned away by the throttle every second or
* third time, which would leave the turning character stopped for a fifth of a
* second at a stretch. There is only one of these timers and it fires ten
* times a second, so nothing here can flicker.
*/
const keepRedrawing = (): void => {
if (redrawing !== undefined) return;
redrawing = setInterval(() => render(true), REDRAW_EVERY_MS);
// A timer must never be the reason this command stays alive.
redrawing.unref?.();
};
const stopRedrawing = (): void => {
if (redrawing === undefined) return;
clearInterval(redrawing);
redrawing = undefined;
};
return {
mode,
colours,
wait(waitingFor: string): void {
if (waits === 0) {
waitStartedAt = now();
detail = undefined;
underneath = undefined;
}
waits += 1;
label = waitingFor;
keepRedrawing();
render();
},
relabel(waitingFor: string): void {
label = waitingFor;
detail = undefined;
// The same wait is doing something else now, so an explanation of what
// held up the thing it was doing before is out of date and comes off.
underneath = undefined;
render();
},
update(hasGotTo: string): void {
detail = hasGotTo;
render();
},
note(saying: string | undefined): void {
underneath = saying === undefined ? undefined : (): string => saying;
// This redraw insists rather than waiting for the next one, because the
// one sentence this row carries at the end of a wait says the request has
// gone out again this instant, and a server that refuses it again a few
// milliseconds later would otherwise take that sentence off the screen
// before it was ever drawn. Measured on 2026-08-20 against the real
// server, waiting for the next redraw instead: of the 19 pauses in one
// run, the sentence reached the screen on one of them. Insisting here
// put it on the screen at 18 of those 19, the last being the pause the
// run was killed in the middle of.
// Nothing on this row arrives fast enough for insisting to make the
// screen flicker; the model's thinking, which does, goes nowhere near it.
render(true);
},
countdown(milliseconds: number, wording: (secondsLeft: number) => string): void {
// The moment the wait is over is worked out once, here, and the row is
// built from the clock on every redraw. So the number on the row falls as
// the wait runs out, without this file holding a timer of its own: the
// redraw that already runs ten times a second is what moves it.
const endsAt = now() + Math.max(0, milliseconds);
underneath = (): string => wording(secondsLeftUntil(endsAt));
render();
},
stop(): void {
waits = Math.max(0, waits - 1);
if (waits > 0) return;
detail = undefined;
underneath = undefined;
render(true);
},
judgeIsQuick(quick: boolean): void {
quickRun = quick;
},
judge(event: ProgressEvent): void {
if (event.kind === 'step-started' || event.kind === 'error') return;
judgeStartedAt ??= now();
keepRedrawing();
if (event.kind === 'call-started') {
runModel = event.model;
if (runPostings === 0) runPostings = wholeRun(event);
open.set(event.call, {
id: event.id,
postings: event.postings,
of: event.of,
latest: NOTHING_YET,
thinking: '',
verdicts: 0,
});
} else if (event.kind === 'thinking') {
const one = open.get(event.call);
// The provider sends the thinking in tiny pieces, often a few characters
// and sometimes a single letter. Keeping only the newest piece, which is
// what this did until 2026-08-19, made the row read as noise: a row
// saying "thinking: A" and a moment later "thinking: is a no. He's
// also". So every piece is added to the end of what this call has
// already thought, and the row shows the end of the joined text.
if (one) {
const joined = oneLine(`${one.thinking}${event.text}`);
one.thinking =
joined.length <= THINKING_KEPT ? joined : joined.slice(joined.length - THINKING_KEPT);
}
} else if (event.kind === 'answer') {
const one = open.get(event.call);
// The answer itself takes the row over, so the thinking behind it is
// thrown away rather than kept for a row that will not show it again.
if (one) {
one.latest = `answer ${withSeparators(event.chars)} chars`;
one.thinking = '';
}
} else if (event.kind === 'verdict') {
done += 1;
// The call's row comes off as soon as the call has produced everything
// it was carrying. The verdict itself is never printed here: the group
// of lines is the only thing that moves while the run is going, and
// every verdict is printed by the judge command when the run has ended
// (Andrew, 2026-08-19).
const one = open.get(event.call);
if (one) {
one.verdicts += 1;
if (one.verdicts >= one.postings) open.delete(event.call);
}
} else if (event.kind === 'call-failed') {
failedCalls += 1;
open.delete(event.call);
}
render();
},
finish(): void {
stopRedrawing();
if (drawn) {
draw.clear();
drawn = false;
}
// Gives the cursor back, which the redrawing hid. Nothing was hidden if
// nothing was ever drawn, so nothing is written in that case.
if (everDrawn) draw.done();
everDrawn = false;
waits = 0;
judgeStartedAt = undefined;
open.clear();
},
restoreCursor(): void {
options.stderr.write(SHOW_CURSOR);
},
};
}
+33
View File
@@ -0,0 +1,33 @@
/**
* The version number this copy of the `pinloop` command reports about itself.
*
* Why this file exists. Once `pinloop` is installed from npm onto somebody
* else's machine, nobody here can reach it. A copy installed in September keeps
* running in November exactly as it was, until the person who installed it
* chooses to install it again. The one thing that copy can do is say which
* version it is, on every request it sends, so the server can answer with the
* newest published version and the copy can print a line telling its owner that
* a newer copy exists. This constant is that number.
*
* What breaks silently if this number is wrong. The number below has to be the
* same number the published package carries. scripts/pack-cli.mjs reads the
* `version` field of the repository's package.json and copies it straight into
* the package people install, so if package.json were raised to 0.2.0 and this
* constant were left at 0.1.0, every installed copy of 0.2.0 would report 0.1.0
* to the server. The server would compare 0.1.0 against a newest published
* version of 0.2.0, decide those people were out of date, and print the update
* line at people who had already updated. Nothing would fail, and installing
* again would not stop the line, because installing again would not change the
* number being reported. A test in src/cli/cli-version.test.ts reads both this
* constant and package.json and fails the moment the two stop matching.
*
* This file lives in src/cli because the published package is built out of
* src/cli and src/shared alone. It imports nothing, and reading package.json at
* run time is deliberately not done here: the published package's own
* package.json sits at a different place relative to this file than the
* repository's does, so a file read would be a thing that works in the test
* suite and breaks in the thing people install.
*/
/** The version this copy of the command is. Must equal package.json's `version`. */
export const CLI_VERSION = '0.3.0';
+141
View File
@@ -0,0 +1,141 @@
/**
* The names paying Pinloop twenty dollars a month is written from: the four
* addresses on the server, the two pages on pinloop.ai, the two names carried
* inside a web address, how long a one-time code is good for, and the two lines
* `pinloop billing` prints.
*
* Nothing here is implementation. Every name below is an address, a number or a
* printed sentence that the installed `pinloop` command, the server and the
* tests all read out of this one file, so that none of the three carries its own
* copy and drifts from the other two. It is the same arrangement
* src/shared/sign-in.ts has held since browser sign-in shipped.
*
* The feature is specs/feature-subscription.md, DRAFT 2026-08-21, with the
* architecture approved the same day. In one paragraph: `pinloop billing` asks
* the server for a web address and opens it. An account that pays nothing is
* handed Pinloop's own payment page at pinloop.ai/upgrade, carrying a one-time
* code the server parked in its memory. The page's script trades that code, once,
* for the secret Stripe's own card form needs, and Stripe's script draws the
* card form inside a sealed frame on that page. An account that already pays is
* handed an address at Stripe instead, which is where cancelling a subscription
* and replacing a card happen.
*
* This file lives in src/shared because both the installed command and the
* server read it: src/cli/package-boundary.test.ts allows the published command
* to be built out of src/cli and src/shared and nothing else.
*/
import { ADDRESS_INDENT } from './sign-in.ts';
// ---------------------------------------------------------------------------
// The four addresses on the server
// ---------------------------------------------------------------------------
/**
* Where `pinloop billing` asks for the address a person should open in a
* browser. It requires a pass, like every other command.
*
* The answer is one field, `url`. For an account with no live subscription it is
* the payment page on pinloop.ai carrying a one-time code; for an account with
* one it is an address at Stripe.
*/
export const BILLING_PATH = '/billing';
/**
* Where the payment page's script trades the one-time code, once, for the secret
* Stripe's card form needs. It takes no pass at all: a browser does the trading,
* and the browser holds no Pinloop login.
*
* The answer carries `client_secret` (what Stripe's script in the browser needs
* to draw the card form), `publishable_key` (the key that names the Stripe
* account, which is meant to be public), and `checkout` (the one-time value the
* finished page asks about below).
*/
export const BILLING_TRADE_PATH = '/billing/trade';
/**
* Where the page a person lands on after paying asks how the payment stands. It
* takes no pass either, and answers `{"finished": true|false}`.
*
* The value it is handed is NOT Stripe's own identifier for the checkout. It is
* a one-time value the server invented when it created that checkout and
* remembers alongside Stripe's identifier for it, and the server answers only
* for a value it invented itself.
*/
export const BILLING_STATUS_PATH = '/billing/status';
/**
* Where Stripe delivers its own messages about finished payments, renewals,
* failed charges and cancellations. It takes no pass: the signature Stripe puts
* in the `stripe-signature` request header stands in for one, and a body whose
* signature does not check out is refused.
*/
export const STRIPE_NOTICE_PATH = '/stripe/notice';
/** The request header Stripe puts its signature in. */
export const STRIPE_SIGNATURE_HEADER = 'stripe-signature';
// ---------------------------------------------------------------------------
// The two pages on pinloop.ai
// ---------------------------------------------------------------------------
/** The page a person who pays nothing types their card into. */
export const UPGRADE_PAGE_PATH = '/upgrade';
/** The page Stripe sends the browser back to when the card form is finished. */
export const UPGRADE_DONE_PAGE_PATH = '/upgrade/done';
/** The name in the payment page's own address that carries the one-time code. */
export const BILLING_CODE_PARAMETER = 'code';
/**
* The name in the finished page's address that carries the one-time value the
* server invented for one checkout.
*/
export const CHECKOUT_PARAMETER = 'checkout';
// ---------------------------------------------------------------------------
// How long a parked code lives
// ---------------------------------------------------------------------------
/**
* How long the one-time code in the printed address can be traded for a card
* form: ten minutes, and once.
*
* It is the same ten minutes a sign-in's short code is good for, for the same
* reason: long enough that a person can walk to another machine and open the
* page there, short enough that a code read off a screen is worthless by the
* time anybody could go looking for it.
*/
export const BILLING_CODE_LIFETIME_MS = 10 * 60 * 1000;
// ---------------------------------------------------------------------------
// The two lines `pinloop billing` prints
// ---------------------------------------------------------------------------
/**
* The line printed above the address, with the address on its own indented line
* under it.
*
* It names no price and no amount of money, because nothing this product prints
* ever does (.claude/skills/printed-message/SKILL.md, rule 1). It is true
* whichever of the two addresses the server hands back, because the terminal
* does not know which it got: setting up a subscription happens on Pinloop's own
* payment page, and changing or cancelling one happens on Stripe's page, and
* "set up or change" covers both.
*/
export const BILLING_OPEN_LINE =
'open this address in your browser to set up or change your Pinloop subscription:';
/**
* The two lines together, ready to be written to standard output: the sentence,
* then the address alone on an indented line.
*
* The address is always printed, even on a machine where the command also opens
* the browser itself, because the person running the command is often a coding
* agent while the human is looking at a browser on another screen, and because a
* rented machine reached over SSH has no browser at all. `pinloop login` prints
* its address the same way and for the same reasons.
*/
export function billingLines(url: string): string {
return `${BILLING_OPEN_LINE}\n${ADDRESS_INDENT}${url}\n`;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* How much of what somebody asked for was actually covered, and the one way this
* product says it.
*
* Andrew's rule, 2026-08-15. Whenever this product tells somebody that something
* was left out, failed, dropped, skipped or not found, the first thing it says
* is how much of the whole did come through, written as a fraction of the real
* numbers. Not "4 postings without embeddings were not considered", but
* "996/1000 postings had embeddings and were considered". The fraction goes
* first, and the list of which ones and why goes after it.
*
* Two things about the numbers. They are never reduced: 249 out of 250 is
* written 249/250, not 249/250 turned into anything smaller, and 996 out of 1000
* stays 996/1000 however large the numbers get. And when there was nothing to
* cover in the first place, the answer is "0/0" rather than a division nobody
* can do.
*
* In an answer a program reads, the two numbers travel as plain whole numbers in
* an object of exactly this shape, sitting next to whatever the answer already
* carried:
*
* "coverage": { "covered": 996, "total": 1000 }
*
* Nothing that was already in an answer was taken away to make room for it, so a
* program that reads `not_found` or `dropped` today still reads them.
*
* In text a person reads, the same two numbers are written with thousands
* separators, because 149,446/149,519 can be read at a glance and 149446/149519
* cannot. Writing them that way is the command line's job and lives in
* src/cli/format.ts: the server hands back the two whole numbers and nothing
* else, and only the program a person is looking at turns them into a sentence.
*
* This file sits under src/shared/ because both sides run it. The server works
* the numbers out; the command line reads them off an answer and prints them.
* Nothing here opens a connection, reads a credential or writes SQL, which is
* what lets the published `pinloop` package carry it without carrying the
* server.
*/
/** How many of a set were covered, and how many there were in all. */
export type Coverage = {
/** How many came through: the successes. */
covered: number;
/** How many there were altogether, the covered ones included. */
total: number;
};
/** The two numbers, in the one shape every answer in this product uses. */
export function coverageOf(covered: number, total: number): Coverage {
return { covered, total };
}
+162
View File
@@ -0,0 +1,162 @@
/**
* The filter verb: keep the rows that satisfy every rule, and say why each of
* the others was dropped.
*
* This file deliberately knows nothing about where rows came from. It is handed
* rows that some other verb already produced, it reads only the fields those
* rows carry, and it hands back two lists. Nothing here opens a connection,
* reads a credential, or writes SQL — a run of `pinloop filter` works with the
* network unplugged.
*
* The rules speak the vocabulary a person types on the command line — country,
* workplace, employment, posted-after — and the rows carry the column names the
* database stores: countries (a list), workplace_type (one value),
* employment_type (a list), posted_at (a timestamp).
*
* Two promises hold for every run. Each row handed in comes back exactly once,
* either among the survivors or in the drop report, so a row can never go
* missing quietly. And when a row is dropped, the report names the rule that
* dropped it, so a person reading standard error knows which condition to relax.
* Every run also says how many rows satisfied every rule out of how many were
* handed in, under `coverage`, and that fraction is the first thing the drop
* report says (src/shared/coverage.ts).
* A row that carries nothing at all for the field a rule reads is dropped by
* that rule, because a rule about countries cannot be satisfied by a row that
* stores no countries.
*/
import { coverageOf, type Coverage } from './coverage.ts';
/** The conditions a person asked for, in the words they typed them in. */
export type FilterRules = {
country?: string | undefined;
workplace?: string | undefined;
employment?: string | undefined;
posted_after?: string | undefined;
};
/** One row that did not survive, and the rule it failed. */
export type FilterDrop = {
id: string;
reason: string;
};
/** What one run of the filter produced. */
export type FilterAnswer<Row> = {
survivors: Row[];
dropped: FilterDrop[];
/** How many rows satisfied every rule, out of how many were handed in. */
coverage: Coverage;
};
/** The fields of a row this file reads, all of them optional. */
type StoredFields = {
id?: unknown;
countries?: unknown;
workplace_type?: unknown;
employment_type?: unknown;
posted_at?: unknown;
};
/** A list of strings out of a stored field, or undefined when there is no list. */
function storedList(value: unknown): string[] | undefined {
if (Array.isArray(value)) return value.map((one) => String(one));
if (typeof value === 'string' && value !== '') return [value];
return undefined;
}
/** A moment in time out of a stored value, or undefined when there is none. */
function storedMoment(value: unknown): number | undefined {
if (value === null || value === undefined || value === '') return undefined;
const when = new Date(value as string | number | Date).getTime();
return Number.isNaN(when) ? undefined : when;
}
/**
* The moment a posted-after rule compares against, or a refusal in the words a
* person reads when what they typed is not a date.
*
* This is separate from the walk over the rows below because it is the one part
* of a filter rule that can be wrong on its own, before any row is looked at.
* The routines feature checks a stored filter step's arguments at the moment the
* routine is stored, when there are no rows to walk, and it calls this.
*/
export function cutoffInstant(postedAfter: string): number {
const cutoff = new Date(postedAfter).getTime();
if (Number.isNaN(cutoff)) {
throw new Error('posted-after must be a date written as YYYY-MM-DD (for example 2026-06-01)');
}
return cutoff;
}
/**
* The one rule this row fails, or undefined when it passes them all. The rules
* are checked in a fixed order — country, workplace, employment, posted-after —
* so a row failing two of them is always reported against the same one, and two
* runs of the same command say the same thing.
*/
function firstRuleThisRowFails(row: StoredFields, rules: FilterRules): string | undefined {
if (rules.country !== undefined && rules.country !== '') {
const countries = storedList(row.countries);
if (countries === undefined) {
return `the country rule: this posting stores no countries at all, so it cannot be in ${rules.country}`;
}
if (!countries.includes(rules.country)) {
return `the country rule: this posting's countries are ${countries.join(', ')}, not ${rules.country}`;
}
}
if (rules.workplace !== undefined && rules.workplace !== '') {
const workplace = row.workplace_type;
if (workplace === null || workplace === undefined || workplace === '') {
return `the workplace rule: this posting stores no workplace kind, so it cannot be ${rules.workplace}`;
}
if (String(workplace) !== rules.workplace) {
return `the workplace rule: this posting's workplace is ${String(workplace)}, not ${rules.workplace}`;
}
}
if (rules.employment !== undefined && rules.employment !== '') {
const employment = storedList(row.employment_type);
if (employment === undefined) {
return `the employment rule: this posting stores no employment label at all, so it cannot be ${rules.employment}`;
}
if (!employment.includes(rules.employment)) {
return `the employment rule: this posting's employment labels are ${employment.join(', ')}, not ${rules.employment}`;
}
}
if (rules.posted_after !== undefined && rules.posted_after !== '') {
const cutoff = cutoffInstant(rules.posted_after);
const when = storedMoment(row.posted_at);
if (when === undefined) {
return `the posted-after rule: this posting stores no posted date, so it cannot be on or after ${rules.posted_after}`;
}
if (when < cutoff) {
return `the posted-after rule: this posting was posted ${String(row.posted_at)}, before ${rules.posted_after}`;
}
}
return undefined;
}
/**
* Splits the rows into the ones that satisfy every rule and the ones that do
* not, with a plain sentence for each drop. Takes rows and rules, and nothing
* else, and returns as soon as it has walked the rows once.
*/
export function runFilter<Row>(rows: readonly Row[], rules: FilterRules): FilterAnswer<Row> {
const survivors: Row[] = [];
const dropped: FilterDrop[] = [];
for (const row of rows) {
const reason = firstRuleThisRowFails((row ?? {}) as StoredFields, rules);
if (reason === undefined) {
survivors.push(row);
} else {
dropped.push({ id: String((row as StoredFields | null)?.id ?? ''), reason });
}
}
return { survivors, dropped, coverage: coverageOf(survivors.length, rows.length) };
}
+875
View File
@@ -0,0 +1,875 @@
/**
* The written text `pinloop guide` prints: one entry per command, plus the
* sections that are not about any one command.
*
* The instructions are not one long document typed out by hand. The builder in
* src/shared/guide.ts walks the real command tree the installed program builds
* and looks up, for each command it finds, the entry here keyed by that
* command's name. The names are spelled exactly the way src/core/catalog.ts
* spells them, with the words joined by single spaces: `search`, `profile put`,
* `tab add`. A command with no entry here makes src/shared/guide.test.ts fail
* naming that command, so nobody can add a command to Pinloop and leave it
* undocumented by accident.
*
* Three things are deliberately not in this file. There is no count of what one
* account has spent this month, because a number written here would be compiled
* into a copy of the program somebody installed weeks ago and would be wrong;
* the guide names `pinloop` typed on its own as the place those numbers are
* read. There are no dollar amounts anywhere. And there is no instruction
* telling an agent to ask the person before spending money (Andrew, 2026-08-18,
* emphatic, reaffirmed 2026-08-19): what protects the person's money is the
* allowance the server enforces, and a sentence asking permission protects
* nobody while making every run slower.
*
* The two numbers the "limits" section does state — how many requests an
* account may make in a minute, and the most postings one command may pull —
* are read from src/shared/limits.ts rather than typed out here, so that a copy
* of them cannot go on saying 60 after the server has moved to 120
* (specs/feature-pull-ceiling.md, section 7).
*
* Lane L on the pre-launch board wrote every sentence below, finished 2026-08-21
* once the two lanes that still touched the command surface had settled their
* shapes: the monthly allowances on branch judging-allowance, and the
* subscription command on branch subscription. The entry keyed `billing` is
* written for a command those branches add. Until one of them ships it, the walk
* finds no such command and the entry is simply never printed, which is why
* writing it early breaks nothing.
*
* The five worked examples in the `examples` section are real runs against
* https://api.pinloop.ai on 2026-08-21, pasted as they came back. Where output
* was cut short the text says so. Two mistakes in the draft they replaced were
* found by running it: the draft told an agent to type `--country CA`, which
* matches nothing because postings carry a country's full name, and it spelled a
* routine's search step `words` when the argument is `q`.
*/
import {
MAX_MESSAGE_CHARS,
MESSAGES_PER_DAY,
PULL_CEILING,
RATE_LIMIT_REQUESTS,
} from './limits.ts';
/**
* The two numbers the message section states out loud, written the way a person
* reads them: ten thousand as 10,000 rather than 10000.
*
* They are built from the constants the server enforces rather than typed here,
* so a change to either number changes both the limit and the sentence that
* describes it. The locale is named rather than left to the machine, so the
* printed text is the same on every machine.
*/
const inFigures = (howMany: number): string => howMany.toLocaleString('en-US');
/**
* One of the two monthly counts, exactly as the server hands it over: how many
* of that count this account has used this month, what its limit is or the word
* saying it has none, and the day the count returns to zero.
*/
export type PrintableAllowance = {
used: number;
limit: number | string;
resets_at: string;
};
/**
* The word the server sends in place of a number for a count this account has no
* limit on.
*/
const NO_LIMIT = 'unlimited';
/**
* The two lines the bare `pinloop` command prints about this month's judging and
* meaning-based searching, built out of the numbers the server sent.
*
* Every number in them comes from the server (specs/feature-judging-allowance.md,
* AGREED 2026-08-21, architecture point 8). Nothing here holds a copy of any
* limit, so the day Andrew changes one, every copy of the command anybody has
* already installed starts printing the new one without being upgraded.
*
* A count with no limit prints what it has used and says there is no limit,
* rather than a fraction of nothing. A count whose numbers the server did not
* send — an older server answering a newer command — prints no line at all,
* which is better than a line built out of guesses.
*/
export function allowanceLines(allowances: unknown): string[] {
const held = (allowances ?? {}) as Record<string, unknown>;
const lines: string[] = [];
const judge = slotIn(held['judge']);
const semantic = slotIn(held['semantic']);
const fullJudgmentCosts = costOfOneFullJudgment(held['quick_postings_per_full_judgment']);
if (judge) {
const left = leftOf(judge);
// How many of those postings this account may still have judged on its own,
// with the whole job description sent to the model. It is the figure above
// divided by what the server said one such judgment costs, rounded down
// (specs/feature-quick-judging-limits.md, AGREED 2026-08-22). Rounded down,
// because a person told they may judge two postings and then refused the
// second has been lied to; and no fraction is ever printed, because "1.28
// full judgments" teaches a person nothing.
const fullJudgments =
fullJudgmentCosts === undefined ? undefined : Math.floor(left / fullJudgmentCosts);
lines.push(
judge.limit === NO_LIMIT
? `Judging: ${judge.used} judged postings this month, no limit`
: `Judging: ${left} of ${judge.limit} judged postings left this month` +
(fullJudgments === undefined
? ''
: `, or ${fullJudgments} judged one at a time`) +
`, resets ${judge.resets_at}`,
);
}
if (semantic) {
lines.push(
semantic.limit === NO_LIMIT
? `Search by meaning: ${semantic.used} searches by meaning this month, no limit`
: `Search by meaning: ${leftOf(semantic)} of ${semantic.limit} left this month, ` +
`resets ${semantic.resets_at}`,
);
}
return lines;
}
/**
* What one posting judged on its own costs out of the judging count, exactly as
* the server sent it (specs/feature-quick-judging-limits.md, AGREED
* 2026-08-22).
*
* The installed command holds no copy of this number, for the same reason it
* holds no copy of any limit: a number typed in here would go on being printed
* after Andrew changed it, on every copy anybody had already installed. A server
* that sent nothing usable — an older server answering a newer command — makes
* this undefined, and the judging line then prints the one figure it can be sure
* of rather than a second figure built out of a guess.
*/
function costOfOneFullJudgment(value: unknown): number | undefined {
const cost = Number(value);
if (!Number.isFinite(cost) || cost < 1) return undefined;
return Math.floor(cost);
}
/** One of the two counts, when the server really sent all three of its parts. */
function slotIn(value: unknown): PrintableAllowance | undefined {
if (value === null || typeof value !== 'object') return undefined;
const held = value as Record<string, unknown>;
const used = Number(held['used']);
const limit = held['limit'];
const resetsAt = held['resets_at'];
if (!Number.isFinite(used)) return undefined;
if (typeof limit !== 'number' && typeof limit !== 'string') return undefined;
if (typeof resetsAt !== 'string') return undefined;
return { used, limit, resets_at: resetsAt };
}
/**
* How many of one count are left this month. It never goes below zero: a count
* that somehow sits past its limit has none left, and a negative number in front
* of a person would only read as a mistake.
*/
function leftOf(slot: PrintableAllowance): number {
const limit = Number(slot.limit);
if (!Number.isFinite(limit)) return 0;
return Math.max(0, limit - slot.used);
}
/**
* The one sentence printed at the very bottom of the guide when nobody is
* signed in, set apart by a blank line.
*
* It sits at the bottom rather than the top because somebody who has just run
* the guide is looking at the end of their screen, not the start of it. It names
* the command to run, because a person who reads it and cannot see what to type
* has learned nothing.
*
* The command it names is `pinloop login` and there is no other
* (specs/feature-browser-sign-in.md, agreed 2026-08-19). Signing in happens in a
* browser, no password exists anywhere in this product, and the first time an
* address signs in the account is created at that moment, so there is nothing a
* separate sign-up step would do.
*/
export const SIGNED_OUT_SENTENCE =
'Nobody is signed in on this machine. Run "pinloop login" and finish signing in in the browser; the first sign-in creates the account.';
/**
* The notice that opens the guide when the agent's saved copy of the short
* instruction file is older than the one this program ships.
*
* It tells the agent to tell the person before replacing the file. A file that
* changes on somebody's machine without them being told is a surprise, and the
* person may have edited that file themselves.
*/
export function staleSkillNotice(saved: number, newest: number): string {
return `The copy of the Pinloop instruction file you have saved is out of date.
The copy you are holding is version ${saved}, and this Pinloop ships version ${newest}.
Tell the person their saved Pinloop instruction file is being replaced, then run "pinloop skill" and save the text it prints over the old file.`;
}
/**
* One entry per command and per non-command part.
*
* The keys are the exact names the catalog spells. Nothing here is generated:
* this is where a person writes what a command is for.
*/
export const GUIDE_TEXT: Record<string, string> = {
// -------------------------------------------------------------------------
// The parts that are not about any one command
// -------------------------------------------------------------------------
overview: `Pinloop is a job search tool driven from a terminal. It holds a large collection
of real job postings, it stores written documents describing the person you are
working with, and it reads postings against those documents and says, for each
posting, whether it suits them and why.
You are the one who types these commands. The person you are working with talks
to you, and you run the commands and read the answers back to them.
Pinloop searches postings and judges them. It does not fill application forms in
and it does not submit applications. If the person expects that, say so plainly
rather than letting them find out later.
Everything below is generated from the commands this copy of Pinloop actually
has, so it never describes a command that is not on this machine.`,
asking: `Things the person can say to you, when they do not know what to ask for. Read
these back to them.
"Find me remote backend internships in Canada posted this week."
"Store my resume and a paragraph about what I am looking for."
"Go through these and tell me which ones are worth applying to, and why."
"Keep a list of the ones you thought were strong."
"Check every morning for new postings that match, and add them to that list."
"Show me what you have on file about me."`,
json: `Every command that hands back rows will print machine-readable output instead of
readable lines when you add --json. The shape is always the same: one JSON
object on standard output, holding a "rows" list, plus a "cursor" where there is
another page to ask for. Nothing else goes to standard output, so the output of
one command can be piped straight into the next one.
Anything a person needs to read but the next command must not swallow goes to
standard error instead: the report of what a search actually looked for, what a
filter dropped, warnings, and refusals.
Four commands take no --json, because they hand back no rows: login, logout,
profile put and profile delete. Three more take none because they print prose:
welcome, skill and guide.`,
limits: `One page is at most 100 postings. When you want everything rather than one page,
add --all and the command follows every page itself and prints the whole thing
at once.
A set of posting ids named in one request may hold up to 10,000 of them. More
than about 3,000 will not fit on a command line at all, because a shell cannot
carry that much text as one argument.
--within takes its ids two ways, and the second one is the one to use with a
pipe: either the ids themselves separated by commas, or a single dash, which
means read them from the JSON piped in. So "pinloop tab get shortlist --all
--json | pinloop list --within - --unjudged" reads a tab and keeps only what is
still unjudged. Without the dash the command asks for an argument and stops.
"pinloop fetch" is the other one that reads a piped set, and it needs no dash:
give it ids or give it none and pipe the JSON in. Everything else that takes
posting ids, "pinloop judgment delete" and "pinloop judgment get" among them,
takes them as words on the command line only and reads nothing from a pipe.
One account may hold 100 tabs, each holding up to 1,000 postings; 10 schedules;
and 10 watches. A routine may hold up to 20 steps.
One judge run judges up to 100 postings. One quick screening run takes up to
1,000 postings and sends them to the model in groups of up to 100.
One command may pull at most ${PULL_CEILING.toLocaleString('en-US')} postings. A --all run over conditions
matching more than that is refused before it fetches anything, in a sentence
naming how many postings matched, so narrow the conditions or walk the pages
yourself with --limit and --cursor.
An account may make up to ${RATE_LIMIT_REQUESTS} requests a minute. Past that the server refuses
and says how long to wait; with --all the command waits it out for you rather
than skipping a page.
Judging and searching by meaning are the two things an account may only do so
much of in a month. Both counts reset at the start of each calendar month. No
number for either one is written here on purpose: a number written into these
instructions is compiled into the copy of Pinloop somebody installed weeks ago
and would be wrong by the time they read it. Type "pinloop" on its own and it
prints how much of each is left and the date they reset.
A judge run that asks for more postings than the month's judging usage limit
covers judges as many as it covers and then stops, and it ends by saying how
many postings it did not attempt and why, rather than failing. Once nothing is
left, judge and a search with --semantic are refused in a sentence saying when
the usage resets. Reading back a verdict this account already holds counts
against nothing, and neither does --preview.`,
examples: `Five runs from start to finish, with the output Pinloop really printed.
Each line that starts with a $ is what was typed; everything under such a line
is what came back. Where output has been cut short, the text says so.
1. FIND POSTINGS, THEN READ ONE OF THEM WHOLE
$ pinloop search backend intern --posted-after 2026-07-01 --limit 3
100/3,719 postings that matched came back. This search hands back the best 100 and no more.
searched for postings carrying any one of these words: backend (backend, backends); intern (intern, interns, internship, internships)
Software Engineer Intern, Fullstack - 12 Month Internship
Bitpanda
Vienna, Vienna, Austria
2026-07-15
https://job-boards.eu.greenhouse.io/bitpanda/jobs/4918465101
Internship - Search Backend Infra Engineer
Perplexity
Belgrade
2026-07-01
https://jobs.ashbyhq.com/Perplexity/be94e89b-89d5-4f2a-a58b-7929c8d97f92
FP&A Intern (12-month Internship)
feedzai
Portugal
2026-07-21
https://careers.feedzai.com/job_description?gh_jid=8076564
The first two lines went to standard error rather than standard output, so a
following command never swallows them. The third posting shows why judging
exists: the words matched, the job is a finance internship.
A search result carries no description text. Take an id from --json and fetch
that one posting to read all of it:
$ pinloop fetch 154a6760-e853-4a2f-bf25-5e1fc5d59fc7
id: 154a6760-e853-4a2f-bf25-5e1fc5d59fc7
company_id: 2cbb8ed9-8789-4812-aec1-706e91e45bfe
external_id: 4918465101
title: Software Engineer Intern, Fullstack - 12 Month Internship
company: Bitpanda
locations: ["Vienna, Vienna, Austria","Barcelona"]
workplace_type:
employment_type: []
description_text: Who we are&nbsp;
We simplify wealth creation. Founded in 2014 in Vienna, Austria by Eric Demuth,
That run printed 53 lines; the description text carries on for another 40 and is
cut off here. Description text is stored as the job board wrote it, so HTML
pieces like &nbsp; appear in it. Two fields on this posting are empty, which is
ordinary: not every posting says whether it is on-site, and not every posting
says whether it is full time.
2. STORE WHAT PINLOOP JUDGES AGAINST, THEN JUDGE
$ pinloop profile put background < background.txt
stored your 'background' document (389 bytes)
$ pinloop search backend intern --posted-after 2026-07-01 --limit 3 --json | pinloop judge
judging 3 postings with anthropic/claude-sonnet-5 via Anthropic
verdict for 44894d98: no (1 of 3 done)
verdict for 154a6760: weak (2 of 3 done)
verdict for 4f188c1e: strong (3 of 3 done)
3/3 postings you named were judged.
1/4 profile documents were stored and sent to the model.
no constraints document is stored, so nothing could rule a posting out on its own
no preferences document is stored, so the model judged these postings without being told what this person wants
no resume is stored, so the model read no resume
judged 3 postings with anthropic/claude-sonnet-5
weak 154a6760-e853-4a2f-bf25-5e1fc5d59fc7
This is a 12-month fullstack internship requiring Vue.js frontend work plus Java/PHP backend, based in Vienna or Barcelona. Your background is backend-focused (Python/Postgres, Go) and you are explicitly looking for a summer 2027 backend or infrastructure internship, remote or in Europe.
Each verdict is followed by its reasoning, and the reasoning of the other two
postings is cut off here. Read the four lines in the middle: this account had
stored one document out of the four judging can use, and every missing one is
named along with what the model therefore could not do. Storing a resume and a
preferences document changes the verdicts, so store them before trusting a run.
3. SEARCH BY MEANING RATHER THAN BY WORDS
$ pinloop search --semantic --from-profile --top 5 --limit 3
mode: semantic
model: voyageai/voyage-4 via OpenRouter
query: profile documents background (updated 2026-08-22T00:16:55.910Z)
searched with background
not stored in this profile: preferences, resume
closest 5
59,124/59,124 postings in the searched set had a vector and were ranked
Internship - Search Backend Infra Engineer
Perplexity
Belgrade
2026-07-01
https://jobs.ashbyhq.com/Perplexity/be94e89b-89d5-4f2a-a58b-7929c8d97f92
match 0.86
Software Engineer Intern
Serval
San Francisco
2026-07-13
https://jobs.ashbyhq.com/Serval/d7fb089c-db8a-4877-a5f3-73a09e67f54b
match 0.86
Nothing was typed to say what to look for. --from-profile built the text out of
the account's own stored documents, and again the answer named the documents
that were not there to use. The third result is cut off here.
4. KEEP THE GOOD ONES IN A NAMED LIST
$ pinloop tab create strong-2027 --description "backend internships worth applying to"
created your 'strong-2027' tab
$ pinloop search backend intern --posted-after 2026-07-01 --limit 3 --json | pinloop judge --keep strong --json | pinloop tab add strong-2027
added 1 posting to your 'strong-2027' tab
$ pinloop tab get strong-2027
Internship - Search Backend Infra Engineer
Perplexity
Belgrade
2026-07-01
https://jobs.ashbyhq.com/Perplexity/be94e89b-89d5-4f2a-a58b-7929c8d97f92
item af0c4b11-3b71-4314-96e5-11311bea2ffc
Three postings went into judge and one came out, because --keep strong hands on
only the postings judged strong. That second run bought no judging at all: all
three verdicts were already stored from the run above, so they were read back.
The item id on the last line is what "pinloop tab remove" takes, and it names
the posting's place in this tab rather than the posting.
5. SET IT RUNNING WITH NOBODY AT THE KEYBOARD
$ pinloop routine put nightly-backend --description "new backend internships, judged, strong ones kept" --steps '[{"command":"search","args":{"q":"backend intern","posted_after":"2026-07-01","limit":3}},{"command":"judge","args":{"keep":"strong"}},{"command":"tab add","args":{"name":"strong-2027"}}]'
stored your 'nightly-backend' routine (3 steps)
$ pinloop routine run nightly-backend
running your 'nightly-backend' routine, 3 steps, this can take a few minutes
step 1 (search): 3 postings came out, none were handed in
step 2 (judge): 1/3 postings handed in came out
3 reused the judgment this account already had
dropped 154a6760-e853-4a2f-bf25-5e1fc5d59fc7: judged weak, below strong
dropped 44894d98-c3a8-4c05-a7e3-6abf48df6b6c: judged no, below strong
step 3 (tab add): 1/1 postings handed in came out
0/1 postings handed in were put in the tab
$ pinloop watch put new-backend --routine nightly-backend
stored your 'new-backend' watch: runs your 'nightly-backend' routine every hour over the postings that arrive after 2026-07-23T08:00:15.709Z, first run 2026-08-22T01:19:08.315Z
Every step says what it was handed and what it passed on, so a run that produced
nothing tells you which step stopped it. Two lines here report work that was not
done rather than hiding it: the judge step bought nothing because all three
verdicts were already stored, and the tab step put in 0 of the 1 posting it was
handed because that posting was already in the tab.
The watch has no cadence you set. It looks about once an hour, runs the routine
over only the postings that arrived since it last looked, and does nothing at
all in an hour when nothing arrived.`,
// -------------------------------------------------------------------------
// The account commands
// -------------------------------------------------------------------------
login: `Signs in, and saves the pass this machine will use in a file only its owner can
read. There is no password anywhere in Pinloop, and there is no separate command
for creating an account: the first time an email address signs in, the account is
created at that moment, and every later sign-in opens the same one.
Run it and it prints a web address and waits. The person opens that address in a
browser, on any machine, and signs in there with Google, with GitHub, or with a
6-digit code Pinloop emails them. The browser then hands the pass back to the
waiting terminal by itself and the command prints one line saying who is signed
in. Read the address out to the person; on a machine that has a browser the
command also opens it.
When the browser is on a different machine from the terminal, which is what
happens over SSH, the page shows a short code instead. Paste that code into the
terminal that is waiting and the sign-in finishes there. The wait ends after ten
minutes with one line saying so.
The pass is good for about an hour, and when it runs out the same file's renewal
token gets a new one automatically, so a person only ever signs in by hand once
per machine.`,
logout: `Deletes the saved login on this machine and prints one line saying it worked.
It contacts no server: that file is the whole of what being signed in on a
machine means, so deleting it is the whole of signing out. Run on a machine that
has no saved login, it says so and reports success. It signs out this machine
only; a pass saved on another machine is untouched.`,
billing: `Opens the page where the person sets up, changes or cancels their Pinloop
subscription. "pinloop upgrade" is a second name for the same command and prints
the same thing. Neither name takes any option.
It prints a web address and exits at once. It does not wait and it does not
watch for a payment to go through, so there is nothing to keep open. On a
machine that has a browser it opens the address as well as printing it. Read the
address out to the person, because on a machine with no browser printing it is
the whole of what happens.
Where the address leads depends on the account. An account with no subscription
gets Pinloop's payment page, and the address carries a one-time code that stops
working after ten minutes, so run the command again for a fresh one rather than
reusing an old address. An account that already subscribes gets a one-time
address into Stripe's own page, which is where a card is replaced, past charges
are read, and the subscription is cancelled.
Nothing more is printed in the terminal afterwards, whatever the person does in
the browser. A subscription that starts raises this account's monthly judging
and meaning-searching usage limits from the next command onwards, and "pinloop"
typed on its own prints what they have become. A cancelled subscription keeps
the raised usage limits until the last day already paid for and then returns to
the free ones. A renewal whose card fails changes nothing while the card is
retried over about two weeks; if it is never paid the account returns to the
free usage limits.`,
// -------------------------------------------------------------------------
// The five that take postings and hand postings on
// -------------------------------------------------------------------------
search: `Searches the corpus for postings carrying the words you type. Whole words are
matched, widened by plural rules and a small list of paired words, and the best
matches come first by default. --match all requires every word rather than any
one of them, --order newest puts the newest first, --in title looks at the title
only, and --top says how many postings the server may consider before it stops.
--semantic searches by meaning rather than by words: it ranks postings by how
close they are to a piece of text you give it, closest first, and prints a match
strength from 0 to 1 for each one. With --semantic, --from-profile builds that
text out of the account's own stored documents, --min-match sets a strength
cutoff, and --preview prints the text that would be sent without contacting
anybody.
Narrow the result with --country, --workplace, --employment, --posted-after,
--company, --within (a set of posting ids to stay inside) and --unjudged (leave
out everything this account has already judged).
Three of those take fixed values and it is worth knowing them exactly.
--workplace is one of Remote Solely, Remote OK, Hybrid or On-site, and
--employment is one of FULL_TIME, PART_TIME, CONTRACTOR, INTERN or TEMPORARY;
anything else is refused in a sentence listing the real values, so a wrong guess
is loud rather than silent. --country is different and quieter: it takes the
country's full name as the posting carries it, like Canada or United States, and
a two-letter code such as CA is not refused at all, it simply matches nothing and
comes back empty. --country holds one country per run. Typing it twice does not
search both; the last one silently wins, so run one country at a time. A posting
may carry several countries, so a posting matched on Germany can well be listed
under Portugal too.
--limit and --cursor page through the answer, --all follows every page at once,
and --json prints rows a following command can read.
Searching by meaning is one of the two things an account may only do so much of
in a month, and word searching is not counted at all. Asking for a later page of
a meaning search you have already run counts against nothing; only the first
page of each one is counted.
A search runs perfectly well with no words at all, as long as a condition
narrows it. "pinloop search --company <employer id> --all" hands back every
posting that employer has, and a wordless search has no scoring ceiling, so
--top does not apply and it pages through everything the conditions allow. This
is the way to take one employer's whole set, because list has no company
condition.
Every answer also reports what the search actually looked for: which match mode
it used, how many postings it was willing to score, and which spellings of each
word it looked for. A wordless search says so in that line, naming what it
narrowed by instead.`,
list: `Lists postings by exact conditions rather than by words, newest first. Nothing is
matched or ranked: --employment, --country, --workplace and --posted-after pick
postings out by the values stored on them, and they take exactly the values the
searching part of these instructions lists: one of Remote Solely, Remote OK,
Hybrid or On-site for --workplace, one of FULL_TIME, PART_TIME, CONTRACTOR,
INTERN or TEMPORARY for --employment, and one country's full name, like Canada or
United States, for --country. --within stays inside a set of posting
ids, --unjudged leaves out everything this account has already judged, --limit
and --cursor page through the answer, --all follows every page at once, and
--json prints rows a following command can read.`,
fetch: `Prints the complete stored record behind each posting id, description text and
all, which is far more than a search result carries. Give the ids on the command
line, or pipe in the JSON another command printed and give none. A large set has
to be piped in rather than typed, because ten thousand ids do not fit on a
command line. --json prints one JSON object holding the records.`,
filter: `Keeps the postings piped into it that satisfy the conditions you give, and says
on standard error what it dropped and why. It contacts no server at all: it works
on rows another command already printed. --country, --workplace, --employment and
--posted-after are the conditions, and they take the same values the searching
part of these instructions lists. --json prints the survivors as rows a following
command can read.
There is no region or continent condition of any kind. Keeping the postings in
Europe means naming the countries yourself and running filter once per country,
because --country holds one country at a time.`,
judge: `Reads postings against this account's stored documents and stores a verdict for
each one, with the reasoning behind it. Give the posting ids, or pipe in the JSON
another command printed. A verdict is one of four words: no, weak, fair or
strong.
A verdict this account already holds for a posting is read back rather than
judged again, and the answer says which ones were reused. --again judges every
posting named again and writes the new verdict over the stored one. --keep hands
on only the postings judged at or above a word, so a following command sees only
those. --preview prints exactly what would be sent without sending it. --json
prints the verdicts as rows a following command can read.
--quick screens rather than judges: it takes up to 1,000 postings, sends their
plain stored facts without the job description text, in groups of up to 100, and
stores a lighter verdict for each. A full judge run writes over a quick verdict
and says so.
A quick screen sends the model far less about each posting, because it leaves
the job description text out, and it uses far less of this account's month as
well. One posting screened by --quick uses a fourteenth of what one posting
judged in full uses, because a screening call carries up to 100 postings in a
single request and sends no job description with any of them. That is what makes
screening a large number of postings first, and then fully judging only the few
that survive, cheaper for the month than fully judging everything. Nothing lets
an account look at postings for nothing at all, though: narrowing the search
before judging anything is still what saves the most usage.
Because a quick screen never sees the job description, anything stated only in
the description, such as a security clearance or a degree requirement, cannot be
caught by --quick. Only a full judge run reads that text.
What judge sends the model as its instructions is the document stored under the
name judge-prompt, or the one Pinloop ships when nothing is stored there.
--quick reads quick-judge-prompt the same way.
Judging is one of the two things an account may only do so much of in a month.
A run bigger than what is left of the month's usage judges as much as that
covers and ends by naming how many postings it did not attempt and why; the same postings are
listed in the machine-readable output with that reason on each one. A verdict
read back rather than bought counts against nothing. Type "pinloop" on its own
to read how much is left.`,
companies: `Finds employers by the words in their name, the one with the most postings first.
It hands back employers rather than postings, and each line carries the employer
id that search's --company option takes. --limit and --cursor page through the
answer, --all follows every page at once, and --json prints one JSON object
holding the rows.`,
// -------------------------------------------------------------------------
// The account's own documents
// -------------------------------------------------------------------------
'profile put': `Stores one document under a name, replacing whatever that name held before. The
text is read from standard input, or from a file with --file. Seven names mean
something to Pinloop itself: resume, background, preferences, constraints,
application-instructions, judge-prompt and quick-judge-prompt. Any other name of
lowercase letters, digits and dashes is yours to use.
The resume is a real PDF file rather than text: Pinloop checks that it is a PDF
before storing any of it, keeps the bytes in a private store, and reads the words
out of it once so a meaning-based search can use them. --default, on judge-prompt
or quick-judge-prompt only, removes the stored text so runs go back to the
instructions Pinloop ships.`,
'profile get': `Prints one stored document. With no name and --all, prints every document, text
included, as one JSON object. --text prints the words read out of a stored file
rather than the file itself, which is how you read the resume as text. Without
--text the resume comes back as the PDF bytes, so send it to a file rather than
to the terminal.`,
'profile list': `Shows every document this account has stored, with each one's kind, size and
last-updated date. It never prints the text of a document. --json prints one JSON
object holding the documents.`,
'profile delete': `Removes one document, or every document this account has stored with --all. When
the resume is removed, the stored file's bytes are destroyed with the row.`,
// -------------------------------------------------------------------------
// The account's own named lists of postings
// -------------------------------------------------------------------------
'tab create': `Makes one named list of postings. The name is lowercase letters, digits and
dashes. --description says what the tab is for. An account may hold 100 tabs.`,
'tab list': `Shows every tab this account has, with how many postings each one holds. --limit
and --cursor page through the answer, --all follows every page at once, and
--json prints one JSON object holding the tabs.`,
'tab get': `Shows the postings in one tab as they stand in the corpus right now, rather than
as they were when they were added. A posting that has since been taken down comes
back marked as gone rather than quietly disappearing. --sort orders by when a
posting was added or by the date the job was posted, --order chooses newest or
oldest first, --limit and --cursor page through the answer, --all follows every
page, and --json prints rows a following command can read.`,
'tab add': `Puts postings into one tab, either by id on the command line or from the JSON
piped into it, which is how the result of a search or a judge run goes straight
into a list. A tab holds up to 1,000 postings, and a posting already in the tab
is not added twice. This is the one command that changes something and can still
be a step inside a routine, so a routine running overnight can fill a tab.`,
'tab remove': `Takes postings out of one tab, named by the item ids that reading the tab prints.
Nothing about the postings themselves changes.`,
'tab rename': `Gives one tab a different name, keeping everything in it.`,
'tab delete': `Removes one tab and everything in it. The postings themselves are untouched; only
the list goes.`,
// -------------------------------------------------------------------------
// The stored verdicts
// -------------------------------------------------------------------------
'judgment list': `Shows every verdict this account has stored, newest first, each line ending with
who made it. --verdict shows only the verdicts carrying one word, --judged-by
narrows to one source and takes one of three words: "user" for a verdict a person
stored by hand, "pinloop" for one a full judge run produced, and "pinloop-quick"
for one a quick screening run produced. Nothing records whether a judge run was
typed at a keyboard or fired by a schedule or a watch, so both land under
"pinloop" and cannot be told apart. --reasoning prints the reasoning of each one
underneath. --limit,
--cursor and --all page through the answer, and --json prints one JSON object
holding the verdicts.`,
'judgment get': `Shows one stored verdict whole, the reasoning included, for the posting id you
name.`,
'judgment delete': `Removes the stored verdicts for the postings you name, so judging them again
produces a fresh verdict.`,
'judgment put': `Stores a verdict a person made outside Pinloop, so Pinloop counts that posting
judged and leaves it out of later runs. Give one posting id with --verdict and
--reasoning, or pipe in up to 1,000 rows as JSON. Every verdict stored this way
is marked as made by a person, and storing one calls no model and counts against
nothing. A bad row is dropped and named rather than stopping the good ones.
A verdict this account already holds for that posting is written over, whoever
made it, and the answer lists what was replaced and who had made it. There is no
need to delete the old verdict first.`,
// -------------------------------------------------------------------------
// The stored pipelines
// -------------------------------------------------------------------------
'routine put': `Stores a pipeline of commands under a name, replacing whatever that name held
before. The steps are a JSON list, given with --steps or piped in, of up to 20
steps. A step may name only a command that takes postings and hands postings on:
search, list, fetch, filter, judge and tab add. --description says what the
routine is for.
A step's arguments are spelled the way the machine-readable output spells them,
not the way the option is typed at a terminal: the words to search for are "q"
rather than the bare words, and a name of more than one word carries an
underscore, as in "posted_after". An argument a command does not take is refused
when the routine is stored, in a sentence naming the step, the argument and
every argument that command does take, so a wrong name is found once rather than
every night.`,
'routine get': `Shows one stored routine and the steps it holds, in order.`,
'routine list': `Shows every routine this account has stored.`,
'routine delete': `Removes one routine. A routine a schedule or a watch fires is not removed; the
refusal names what is still pointing at it.`,
'routine run': `Runs one routine's steps in order on the server and prints what the last step
produced. --within gives the first step a set of posting ids to work inside,
either as ids or as JSON piped in. --json prints rows a following command can
read. Every step's outcome is kept, so a run that stopped part way says which
step stopped it.`,
'routine results': `Shows what the most recent run of one routine produced, read fresh from the corpus
rather than from a copy taken at the time. --json prints rows a following command
can read.`,
// -------------------------------------------------------------------------
// Running without anybody at the keyboard
// -------------------------------------------------------------------------
'schedule put': `Stores a schedule: one routine, run every so many hours with nobody at the
keyboard. --routine names the routine and --every-hours is a whole number of
hours from 1 to 168, both required. --first-due-at fixes when the first run
happens, and the time of day it lands on is the time of day every later run lands
on. An account may hold 10 schedules.`,
'schedule get': `Shows one schedule, when it next runs, and how its last run went.`,
'schedule list': `Shows every schedule this account has stored, with the next run and the outcome of
the last one.`,
'schedule delete': `Removes one schedule, so the routine it ran stops running by itself. The routine
itself stays.`,
'watch put': `Stores a watch: one routine, run over only the postings that arrived in the corpus
since this watch last looked. --routine names the routine, whose first step has to
be a search or a list, and is required. A watch has no cadence of its own; it
looks about once an hour. Nothing arriving means nothing runs. An account may hold
10 watches. A routine whose last step adds to a tab is what fills a list of new
finds overnight.`,
'watch get': `Shows one watch, when it next looks, and how its last firing went, including how
many postings had arrived and how many of them matched.`,
'watch list': `Shows every watch this account has stored, with the outcome of the last firing of
each one.`,
'watch delete': `Removes one watch, so the routine it ran stops running by itself. The routine
itself stays.`,
// -------------------------------------------------------------------------
// Reaching the person who builds Pinloop
// -------------------------------------------------------------------------
message: `Sends a message to Andrew (who builds Pinloop) and prints the conversation between
this account and him. Typed with words after it, it sends those words. Typed on
its own, it prints the whole back-and-forth, oldest message first. --json prints
that same conversation as rows you can read.
Use it when something goes wrong: a command failed, an answer looks wrong, the
corpus is missing something the person expected, or Pinloop does not do something
they need. Andrew reads these himself and answers.
Never send anything the person has not read. Write the draft yourself, show them
the exact words you are about to send, wait for them to say yes, and only then
run the command. If you are writing about something that broke, paste the command
that failed and the error text it printed into the draft, so the person reads
both before they say yes. Pinloop attaches nothing to the words on its own, which
means an error line holding the path to their resume file goes only if they read
it and left it in.
One message is at most ${inFigures(MAX_MESSAGE_CHARS)} characters, which is long enough for a real
report with a whole error dump pasted into it. One account may send ${inFigures(MESSAGES_PER_DAY)}
messages a day. Going over either one is refused in a sentence saying what the
limit is, and the second of the two also says when the limit lifts.
When a line appears telling you an answer from Andrew is waiting, run this
command and give the person his answer word for word. Once the conversation has
been printed, the line stops appearing.`,
// -------------------------------------------------------------------------
// The three that print text
// -------------------------------------------------------------------------
welcome: `Prints the text that introduces Pinloop to you: what Pinloop is, the short
instruction file to save into your own skills folder, and the first steps to work
through with the person, in order. It prints the same text whether or not
anybody is signed in, and it contacts no server. It is the first thing to run on
a machine where Pinloop has just been installed.`,
skill: `Prints the short instruction file, the few sentences you save in your own skills
folder and read at the start of a conversation. It writes no file anywhere:
every agent tool looks in a different folder, and you are the only one who knows
where yours looks. The text carries a version number, and it ends by telling you
to come back and read these full instructions.
Tell the person before you write this text over a file they already have. The
file sits in their own skills folder and they may have edited it themselves, so
replacing it without saying so takes away something of theirs without asking.
Saving it for the first time needs no such warning. To find out whether the copy
you hold is behind, run "pinloop guide --skill" with the version number written
in your saved copy; the instructions then open with a notice when yours is
older, and print nothing extra when it is current.`,
guide: `Prints these instructions. With no part named it prints all of them; with the
name of a command after it, like "pinloop guide judge", it prints that command's
part only. It works whether or not anybody is signed in. --skill takes the
version number of the instruction file you have saved, and the output opens with
a notice when your saved copy is older than the one this Pinloop ships.`,
};
+152
View File
@@ -0,0 +1,152 @@
/**
* Builds the instructions `pinloop guide` prints.
*
* Nothing here is typed out by hand as one long document. The builder walks the
* real command tree the installed program builds, and for each command it finds
* it looks up the paragraph written for that one command in
* src/shared/guide-text.ts. Two things follow from that. The instructions always
* describe the copy of Pinloop the person actually installed, never a newer or
* older one. And a command added to the program with no paragraph written for it
* stops the build with that command named, which src/shared/guide.test.ts turns
* into a failing test, so nobody ships an undocumented command by accident.
*
* A node in the command tree with children under it — `profile`, `tab`,
* `routine` — groups commands rather than being one, so the walk goes into it
* rather than counting it. The words of a command are joined by single spaces,
* which is exactly how src/core/catalog.ts spells them and how the written text
* is keyed: `search`, `profile put`, `tab add`.
*
* Three things change what comes out:
*
* - A part name. With none, the whole thing prints. With one, only that part
* prints, so `pinloop guide judge` is the judging part and nothing else. A
* name that matches neither a command nor one of the fixed non-command parts
* is refused, and the refusal names the word that was typed and lists every
* name that would have worked.
* - The version number of the instruction file the agent has saved. When it is
* older than the newest one, the output opens with a notice saying so. When
* it is the newest, or higher than any that has ever existed, no notice
* appears, because a notice that shows when nothing is out of date teaches an
* agent to ignore every notice.
* - Whether anybody is signed in. When nobody is, the last thing printed is the
* sentence naming the command that creates an account, set apart by a blank
* line, because somebody who has just run the guide is looking at the bottom
* of their screen rather than the top.
*
* The guide carries no welcome text of any kind. `pinloop welcome` is its own
* command and prints the same text for everybody, so nothing here has to work
* out whether an account is new.
*/
import type { Command } from 'commander';
import { GUIDE_TEXT, SIGNED_OUT_SENTENCE, staleSkillNotice } from './guide-text.ts';
/**
* The part names a person may ask for that are not commands.
*
* This is the one short list of part names kept by hand. Every other part name
* comes from the command tree itself, so nobody anywhere keeps a second list of
* command names.
*/
export const NON_COMMAND_PARTS: readonly string[] = Object.freeze([
'overview',
'asking',
'json',
'limits',
'examples',
]);
/** What a caller hands the builder. */
export type GuideOptions = {
/** The real command tree, as buildProgram() hands it back. */
program: Command;
/** One part name, or absent for the whole thing. */
part?: string;
/** Whether this machine holds a saved login. */
signedIn: boolean;
/** The newest version of the instruction file, as the server answered. */
newestSkillVersion: number;
/** The version the agent typed after --skill, or absent when it typed none. */
savedSkillVersion?: number;
/**
* The written text to build from. Absent means the text this program ships.
* It is an argument so a test can hand in a copy with one command's paragraph
* removed and watch the build refuse.
*/
text?: Record<string, string>;
};
/**
* Every command a person can type, with the words joined by single spaces.
*
* A node with children under it groups commands rather than being one, so the
* walk goes into it and never counts it.
*/
export function commandsIn(program: Command): string[] {
const found: string[] = [];
const walk = (node: Command, words: string[]): void => {
for (const child of node.commands) {
const path = [...words, child.name()];
if (child.commands.length === 0) found.push(path.join(' '));
else walk(child, path);
}
};
walk(program, []);
return found;
}
/** One part, printed under a heading a person can find by eye. */
function section(name: string, text: Record<string, string>, isCommand: boolean): string {
const heading = isCommand ? `pinloop ${name}` : name;
const underline = '-'.repeat(heading.length);
return `${heading}\n${underline}\n\n${text[name]!.trim()}`;
}
/** The whole of the instructions, or one part of them. */
export function buildGuide(options: GuideOptions): string {
const text = options.text ?? GUIDE_TEXT;
const commands = commandsIn(options.program);
// The completeness check comes first, so a command with no paragraph written
// for it stops the build whatever else was asked for.
const undocumented = [...NON_COMMAND_PARTS, ...commands].filter((name) => {
const written = text[name];
return typeof written !== 'string' || written.trim() === '';
});
if (undocumented.length > 0) {
throw new Error(
`the instructions have nothing written for ${undocumented.join(', ')}. ` +
`Every command a person can type needs a paragraph in src/shared/guide-text.ts, ` +
`keyed by the name the catalog spells.`,
);
}
const blocks: string[] = [];
if (
options.savedSkillVersion !== undefined &&
options.savedSkillVersion < options.newestSkillVersion
) {
blocks.push(staleSkillNotice(options.savedSkillVersion, options.newestSkillVersion));
}
if (options.part !== undefined && options.part !== '') {
const wanted = options.part;
const isCommand = commands.includes(wanted);
if (!isCommand && !NON_COMMAND_PARTS.includes(wanted)) {
throw new Error(
`there is no part of these instructions called "${wanted}". ` +
`The parts you can ask for are: ${[...NON_COMMAND_PARTS, ...commands].join(', ')}.`,
);
}
blocks.push(section(wanted, text, isCommand));
} else {
for (const name of NON_COMMAND_PARTS) blocks.push(section(name, text, false));
blocks.push('The commands, one at a time.');
for (const name of commands) blocks.push(section(name, text, true));
}
if (!options.signedIn) blocks.push(SIGNED_OUT_SENTENCE.trim());
return `${blocks.join('\n\n')}\n`;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* The numbers both the server and the installed `pinloop` command have to
* state, kept in one place so neither carries its own copy of them.
*
* Every other limit in this product lives in src/server/limits.ts, which is
* where they are read from and where they are documented. These live here
* instead, and src/server/limits.ts exports them again under the same names, so
* that every file which already reads them keeps reading them from the same
* place.
*
* They are here because the block of instructions the installed `pinloop`
* command prints for a coding agent states them out loud
* (src/shared/guide-text.ts), and the installed command may only be built out
* of src/cli/ and src/shared/ — no database driver, no web framework, none of
* the server (src/cli/package-boundary.test.ts). Each number below is printed
* by those instructions as well as enforced by the server. A number typed into
* the instructions a second time would go on saying 60 after the server had
* moved to 120, so the instructions read the number instead of repeating it.
*/
/**
* The most messages one account may send inside one day
* (specs/feature-messages.md, AGREED 2026-08-20).
*
* Twenty is far more than a person writes in a day, and low enough that a
* coding agent stuck repeating itself overnight cannot write thousands of rows
* into the database before anybody is awake to see it. The count is of rows
* written in the last 24 hours, not a tally held in the server's memory, because
* a tally in memory is emptied by every deploy and a per-day limit that forgets
* itself on every deploy is not a per-day limit.
*
* The one account this is never applied to is the account the server's owner
* setting names, which is Andrew's: he answers many people in one sitting.
*/
export const MESSAGES_PER_DAY = 20;
/**
* The most characters one message may carry
* (specs/feature-messages.md, AGREED 2026-08-20).
*
* Ten thousand is long enough for a real report with the command that failed
* and its whole error dump pasted into it, which is exactly what the
* instructions tell a coding agent to put in the draft.
*/
export const MAX_MESSAGE_CHARS = 10_000;
/**
* How many requests one account may make inside RATE_LIMIT_WINDOW_MS before the
* server starts answering "try again later" (HTTP 429).
*
* Raised from 60 to 120 on 2026-08-20 (specs/feature-pull-ceiling.md, section
* 3). Sixty was a guess made when nothing else in Pinloop bounded anything, and
* nobody had ever measured it. Twenty full pages of `pinloop list` sent to the
* live server at the same instant all came back inside 1.22 seconds, which is
* about a thousand requests a minute on the one machine Pinloop runs on. What
* protects that machine under real load is POOL_WAIT_MS, not this number.
*/
export const RATE_LIMIT_REQUESTS = 120;
/**
* The most postings one typed command may pull, however many pages it follows.
*
* A command that would pull more than this is refused before a single page is
* fetched, in the one sentence pullCeilingRefusal writes
* (specs/feature-pull-ceiling.md, section 2). It covers the two commands that
* page through the bought corpus, `pinloop list` and `pinloop search`, and it
* covers a whole `--all` run rather than one page of one.
*
* Why five thousand: a page holds at most MAX_PAGE_SIZE postings, which is 100,
* so 5,000 postings is 50 requests. An account may make RATE_LIMIT_REQUESTS
* requests a minute, which is 120, and the rule this feature comes from is that
* no single typed command may spend more than half of a minute's allowance.
* Fifty is well under half of a hundred and twenty, and a run of 5,000 postings
* finishes in a few seconds without ever being told to wait.
*/
export const PULL_CEILING = 5_000;
+24
View File
@@ -0,0 +1,24 @@
/**
* How many calls to the model provider Pinloop keeps open at the same time.
*
* This number lives in src/shared/ rather than beside the code that makes the
* calls because two very different pieces of the product need the same figure
* and neither may import the other. The server uses it to decide how many
* postings it sends to the model at once. The `pinloop` command prints it in the
* group of lines it redraws while a judge run goes — "judging 12 postings, 4 at
* a time" — and the command may import nothing from the server
* (src/cli/package-boundary.test.ts walks its imports and fails on any file
* outside src/cli/ and src/shared/). Written down twice, the two copies would
* drift and the line on the screen would quietly start lying about what the
* server is doing.
*/
/**
* How many provider calls may be in flight at once.
*
* One at a time would make a full hundred-posting run take ten minutes or more,
* which is the run the cap allows; a large number is a burst the provider
* throttles. Four keeps a full run inside the low minutes the design promises
* and stays well under any published rate.
*/
export const MAX_IN_FLIGHT_REQUESTS = 4;
+76
View File
@@ -0,0 +1,76 @@
/**
* What the server says to a caller while a judge run is still going, and the
* one content type it says it under.
*
* A caller that wants to watch a run put the header `Accept:
* application/x-ndjson` on the request it already makes. The server then
* answers 200 under that same content type and writes one complete JSON object
* per line, each line ended with a newline, as things happen. Every line but the
* last is one of the events below. The last line is not an event at all: it is
* the complete answer the same request without that header returns, unchanged.
*
* This file lives in src/shared/ because both halves need it. The server writes
* these objects and the `pinloop` command reads them, and the command may import
* nothing from the server (src/cli/package-boundary.test.ts walks its imports and
* fails on any file outside src/cli/ and src/shared/).
*/
/** The content type progress is asked for and answered under: JSON, one object per line. */
export const PROGRESS_CONTENT_TYPE = 'application/x-ndjson';
/**
* One thing that happened while a run was going.
*
* A run makes one model call per posting, or one call per hundred postings when
* it is a quick screen, so `call` and `of` count one call off against the whole
* run: call 3 of 12. The model and the serving company on a call-started event
* are read off the first piece that call's provider sent back, never out of a
* constant, because the model that answers is not always the model that was
* asked for.
*/
export type ProgressEvent =
/** A call's first piece has arrived, so the call is really under way. */
| {
kind: 'call-started';
call: number;
of: number;
/** How many postings this one call carries: one, or up to a hundred. */
postings: number;
/**
* The posting this call is about. A quick screen packs up to a hundred
* postings into one call, and this then holds the first of them, in the
* order the call carried them.
*
* It is here because the group of lines the `pinloop` command redraws
* while a run goes names each open call by the first eight characters of a
* posting's id, and until the call finishes there is nothing else on the
* call to name it by.
*/
id: string;
/** The model that is really answering, as the provider named it. */
model: string;
/** The company serving that model, as the provider named it. */
provider: string;
}
/** A piece of the model's thinking, in the words it arrived in. */
| { kind: 'thinking'; call: number; text: string }
/** How many characters of the answer itself have arrived so far. */
| { kind: 'answer'; call: number; chars: number }
/** One posting's verdict, the moment the call carrying it finished. */
| { kind: 'verdict'; call: number; id: string; verdict: string }
/** A call that could not be finished, with the reason in the words it came in. */
| { kind: 'call-failed'; call: number; of: number; reason: string }
/**
* A step of a stored command sequence has begun. A run of a stored sequence
* says nothing else while it goes: the answer still arrives once, at the end.
* `command` is the command that step names — search, list, filter, fetch, tab
* add or judge — so a person waiting reads what is happening rather than only
* how far along it is.
*/
| { kind: 'step-started'; step: number; of: number; command: string }
/**
* The run itself failed after progress had already started. It is only ever
* the last line, and it is how a failure is reported at all once the status
* number at the top of the answer has gone out as 200.
*/
| { kind: 'error'; message: string };
+307
View File
@@ -0,0 +1,307 @@
/**
* The reserved document names, the size caps, and the exact words every refusal
* carries.
*
* A profile is a set of named documents. Most names are the user's own
* invention and mean nothing to Pinloop beyond "the user keeps this here". A
* short list of names is different: the product itself decides what those names
* are for, so `constraints` is always the document judge reads for work
* authorization and `resume` is always the PDF the fill engine attaches. That
* list is the registry below, and it is data rather than scattered checks so
* that adding a name is one line in one file.
*
* Each reserved name has one fixed kind. `resume` holds an uploaded file; the
* other five hold text. A name that is not in the registry is a legal text
* document, and nothing in the product reads it by itself.
*
* The two caps and the four refusal sentences live here too, because the tests
* that pin them and the code that enforces them have to read the same numbers
* and the same words instead of each keeping a copy.
*/
/** What a document holds: characters in the row, or bytes in file storage. */
export type DocumentKind = 'text' | 'file';
/**
* The names the product itself gives meaning to, each with the one kind it may
* hold. Growing this list is a spec change (specs/feature-profile.md), never a
* code default.
*/
export const RESERVED_NAMES: Readonly<Record<string, DocumentKind>> = Object.freeze({
resume: 'file',
'application-instructions': 'text',
constraints: 'text',
background: 'text',
preferences: 'text',
'judge-prompt': 'text',
'quick-judge-prompt': 'text',
});
/**
* The name whose stored text becomes the instructions judge sends the model
* (specs/feature-judge-prompt.md, AGREED 2026-08-15).
*
* What storing a document under this name does, exactly:
*
* - Its text is sent to the model in place of DEFAULT_JUDGE_PROMPT below,
* whole. Nothing is added to it and nothing is taken off it.
* - It is never sent as one of the labelled profile documents. The model is
* handed the posting and the person's other documents, and this text once,
* as the instructions.
* - A document stored under this name whose text is empty once the spaces and
* line breaks are taken off the ends counts as no document at all: judge
* sends the shipped default instead. Sending the model no instructions would
* still get an answer back, and nobody would ever find out.
* - It is not one of the four documents judge needs. An account that has
* stored this and nothing else is refused before any model is called,
* because there is nothing to judge a posting against.
*/
export const JUDGE_PROMPT_NAME = 'judge-prompt';
/**
* The instructions judge sends the model when the account has stored none of
* its own. Andrew approved this text on 2026-08-15, word for word.
*
* Changing it is meant to be a deliberate act rather than a tidy-up: this text
* decides every verdict the product produces. A copy of it sits in
* src/server/judge-prompt.test.ts, so anyone who edits it here has to edit that
* copy as well and the change shows up in the diff.
*/
export const DEFAULT_JUDGE_PROMPT =
'You are judging one job posting against one person, for a product called Pinloop.\n' +
'\n' +
'You are given the complete stored posting, then every document this person keeps in ' +
'their profile, each labelled with the name it is stored under. Four of those names ' +
'mean something fixed:\n' +
'\n' +
'- constraints: rules this person cannot break, such as work authorization, where they ' +
'can be, and when they are free. If the posting breaks any rule written there, the ' +
'verdict is `no`, whatever else is true, and your reasoning must name the rule it breaks.\n' +
'- background: what this person has done and can do.\n' +
'- preferences: what this person wants and does not want.\n' +
'- resume: attached as a PDF file with this message, when they have stored one. Read it ' +
'as part of their profile.\n' +
'\n' +
'Judge the posting against the background and the preferences together. Any other ' +
"document in the profile is part of this person's profile too: read it and take it into " +
'account.\n' +
'\n' +
'Give one verdict, exactly one of these four words:\n' +
'\n' +
'- `no`: a constraint is broken, or the posting has nothing to do with this person.\n' +
'- `weak`: they could apply, but the posting asks mostly for things they do not have or ' +
'do not want.\n' +
'- `fair`: a real match on the main things, with clear gaps.\n' +
'- `strong`: this person is what the posting is asking for, and the posting is what they ' +
'want.\n' +
'\n' +
'Then give your reasoning, in plain sentences, written for this person to read. Say what ' +
'drove the verdict. Grade honestly: this person wants a judgement they can act on, not ' +
'encouragement.\n' +
'\n' +
'Answer only in the shape you were given.\n';
/**
* The name whose stored text becomes the instructions a quick screening run
* sends the model (specs/feature-quick-judge.md, the Architecture section, call
* 4, approved by Andrew on 2026-08-16).
*
* It behaves exactly the way JUDGE_PROMPT_NAME above behaves, and applies to
* quick screening runs alone: its text replaces DEFAULT_QUICK_JUDGE_PROMPT
* whole, a document holding nothing but spaces counts as none, it is never sent
* as one of the labelled profile documents, and it is not one of the four
* documents a run needs before it will judge anything. An ordinary judge run
* never reads it, and a quick screening run never reads `judge-prompt`.
*/
export const QUICK_JUDGE_PROMPT_NAME = 'quick-judge-prompt';
/**
* The instructions a quick screening run sends the model when the account has
* stored none of its own.
*
* It is not the ordinary judge prompt and cannot be: a quick screening run
* sends up to a hundred postings in one request and sends none of their job
* description text, so the model is being asked for something weaker than a
* judgement and has to be told so in as many words. The four verdict words are
* the same four, because `--keep fair` has to mean the same thing on both kinds
* of run.
*/
export const DEFAULT_QUICK_JUDGE_PROMPT =
'You are screening job postings against one person, for a product called Pinloop.\n' +
'\n' +
'You get up to a hundred postings in this one message, and for each posting you see only ' +
'the plain facts stored about it: the job title, the employer, the locations and the ' +
'countries, the employment labels such as intern or full time, the workplace kind such as ' +
'remote or on site, the pay when any pay is stored, and the date it was posted. You are ' +
'not given the job description, so the requirements written in it are not in front of you. ' +
'Screen on the facts you have and do not guess at what a description you cannot read might ' +
'say. Your ratings decide which of these postings get read properly later, description and ' +
'all. So you are deciding what deserves a real look, not what deserves an application.\n' +
'\n' +
'After the postings comes the list of posting ids you must answer for, and then this ' +
"person's profile: every document they keep, each labelled with the name it is stored " +
'under. Four of those names mean something fixed:\n' +
'\n' +
'- constraints: rules this person cannot break, such as work authorization, where they can ' +
'be, and when they are free. If a posting breaks a rule written there, its verdict is `no`, ' +
'whatever else is true, and your reasoning must name the rule it breaks.\n' +
'- background: what this person has done and can do.\n' +
'- preferences: what this person wants and does not want.\n' +
'- resume: attached as a PDF file with this message, when they have stored one. Read it as ' +
"part of this person's profile.\n" +
'\n' +
'Any other document in the profile is part of this person too: read it and take it into ' +
'account.\n' +
'\n' +
'Give one verdict for every posting id you were given, and for no other id. A verdict is ' +
'exactly one of these four words:\n' +
'\n' +
'- `no`: a constraint is broken, or the posting has nothing to do with this person.\n' +
'- `weak`: nothing rules it out, but the facts you can see point away from this person.\n' +
'- `fair`: worth reading properly. The facts you can see line up, and the description would ' +
'settle the rest.\n' +
'- `strong`: the facts you can see line up well, and this looks like the kind of role this ' +
'person is asking for.\n' +
'\n' +
'`fair` means "worth a real look", not "apply to this one". What settles that is the job ' +
'description, and you have not read it.\n' +
'\n' +
'With each verdict give one or two sentences of reasoning, written for this person to read, ' +
'saying what drove it. Grade honestly: a screen that calls everything strong has told this ' +
'person nothing.\n' +
'\n' +
'Answer for every posting id you were given, including the ones whose facts are thin. A ' +
'posting you say nothing about is reported to this person as one that could not be ' +
'screened.\n';
/** The kind a reserved name must hold, or undefined for a name the user invented. */
export function reservedKind(name: string): DocumentKind | undefined {
return Object.prototype.hasOwnProperty.call(RESERVED_NAMES, name)
? RESERVED_NAMES[name]
: undefined;
}
/**
* What a legal document name looks like: lowercase letters, digits and dashes,
* one to forty characters. The same rule is written as a CHECK constraint on
* the table in supabase/migrations/20260813184000_profiles.sql, so a name that
* somehow got past this one would still be refused by the database.
*/
export const NAME_RULE = /^[a-z0-9-]{1,40}$/;
/** The most bytes one text document may hold. */
export const PER_DOCUMENT_CAP = 65_536;
/**
* The most bytes all of one account's TEXT documents may hold together. An
* uploaded file's bytes never live in the row, so they are not counted here;
* a file is held to FILE_CAP on its own instead.
*/
export const WHOLE_PROFILE_CAP = 262_144;
/** The most bytes one uploaded file may hold. */
export const FILE_CAP = 10 * 1024 * 1024;
/** The one content type an uploaded file may arrive as, today. */
export const FILE_CONTENT_TYPE = 'application/pdf';
/** The filename a file gets when it was piped in and had no name of its own. */
export const DEFAULT_FILE_NAME = 'resume.pdf';
/** The five characters every PDF begins with. */
const PDF_HEADER = '%PDF-';
/**
* True when these bytes start the way every PDF starts.
*
* Two places ask this and they ask it for different reasons. The server asks
* before it stores anything, because a file that does not begin like a PDF is
* refused. The command line asks so that a person who points a PDF at a document
* name that holds text gets the server's refusal about that name, instead of
* having their PDF quietly mangled into characters and stored as text.
*/
export function beginsLikeAPdf(bytes: Buffer): boolean {
return bytes.subarray(0, PDF_HEADER.length).toString('latin1') === PDF_HEADER;
}
/** The one document name that takes a file, named in the refusals below. */
const THE_FILE_NAME = 'resume';
/** How a person is told to store the resume, repeated by several refusals. */
const HOW_TO_STORE_A_FILE =
`"pinloop profile put ${THE_FILE_NAME} --file <path to the PDF>"`;
/** What a person is told when the name they chose is not a legal name. */
export function nameRuleRefusal(name: string): string {
return (
`${JSON.stringify(name)} is not a document name. A name is made of ` +
`lowercase letters, digits, and dashes, up to 40 characters.`
);
}
/** What a person is told when they hand plain text to a name that holds a file. */
export function wrongKindRefusal(name: string): string {
if (name === 'resume') {
return (
'the resume holds a PDF file, not text. Store it with ' +
'"pinloop profile put resume --file <path to the PDF>".'
);
}
return `the document named ${name} holds a file, not text.`;
}
/** What a person is told when they upload a file to a name that holds text. */
export function holdsTextRefusal(name: string): string {
return (
`the document named ${name} holds text, not a file. ` +
`The ${THE_FILE_NAME} is the only document that takes a file: store it with ` +
`${HOW_TO_STORE_A_FILE}.`
);
}
/** What a person is told when the file they uploaded is bigger than the cap. */
export function overFileCapRefusal(size: number): string {
return (
`that file is ${size} bytes, which is over the 10 MB cap for a file ` +
`(${FILE_CAP} bytes).`
);
}
/**
* What a person is told when the file they uploaded does not start with the
* five characters every PDF starts with. This is what somebody handing over a
* text file, a Word document or a screenshot sees, so it also says in as many
* words that the resume takes a PDF file rather than typed-out text.
*/
export function notAPdfRefusal(): string {
return (
'that file does not begin like a PDF: every PDF starts with the characters ' +
`%PDF-, and this one does not. The ${THE_FILE_NAME} holds a PDF file, not text, ` +
`so store the PDF itself with ${HOW_TO_STORE_A_FILE}.`
);
}
/** What a person is told when a PDF-looking file turns out not to open. */
export function willNotOpenRefusal(said: string): string {
return (
`that file starts like a PDF but could not be opened as a PDF (${said}). ` +
'Open it on your own machine, and if it opens there, save it again from the ' +
'program that opened it and upload the saved copy.'
);
}
/** What a person is told when one document on its own is too big. */
export function perDocumentRefusal(size: number): string {
return (
`that document is ${size} bytes, and the cap per document is 64 KB ` +
`(${PER_DOCUMENT_CAP} bytes).`
);
}
/** What a person is told when storing a document would take the profile over. */
export function wholeProfileRefusal(wouldBe: number): string {
return (
`storing that document would put this profile at ${wouldBe} bytes, which is ` +
`over the profile's 256 KB cap (${WHOLE_PROFILE_CAP} bytes).`
);
}
+218
View File
@@ -0,0 +1,218 @@
/**
* STUB. Browser sign-in does not exist yet.
*
* Nothing here is implementation. Every name below is a number, an address, or
* a printed sentence that both the tests of this feature and the build that
* follows them read out of this one file, so that neither side can carry its
* own copy and drift. It is the same arrangement src/cli/paths.ts has held
* since slice 1, and the same one src/server/limits.ts holds for the ceilings.
*
* The feature is specs/feature-browser-sign-in.md, agreed 2026-08-19, with the
* architecture agreed 2026-08-20. In one paragraph: `pinloop login` starts a
* small web server inside its own process listening on 127.0.0.1, prints the
* web address of the sign-in page with the port it is listening on and a
* one-time secret in that address, and waits. A person opens that address in a
* browser, signs in with Google, with GitHub, or with a 6-digit code Pinloop
* emails them, and the script inside the page sends the pass to 127.0.0.1 on
* the port the terminal named. When the browser cannot reach the terminal —
* the terminal is on a rented machine reached over SSH and the browser is on a
* laptop — the page shows a short code instead, and the person pastes that code
* into the waiting terminal.
*
* This file is imported by the command line and by the server, so it lives in
* src/shared: src/cli/package-boundary.test.ts allows the published command to
* be built out of src/cli and src/shared and nothing else.
*/
// ---------------------------------------------------------------------------
// The release this feature ships as
// ---------------------------------------------------------------------------
/**
* The version of the `pinloop` command this feature ships as, and the version
* the server's MINIMUM_CLI_VERSION rises to on the day it ships.
*
* Why the minimum moves, which it almost never does. Every copy of `pinloop`
* published before this one signs a person in by sending their password to two
* server addresses that this feature deletes, so the sign-in in every one of
* those copies is broken beyond repair from here. src/server/versions.ts says
* the minimum moves only when one specific older copy is provably giving people
* wrong answers or doing something unsafe, and a copy whose only way in no
* longer exists meets that bar.
*/
export const BROWSER_SIGN_IN_RELEASE = '0.2.0';
/**
* The release before this one: the last published copy that signs in with a
* password. A request naming this version is the exact thing the server has to
* refuse with the line naming `npm install -g pinloop`.
*/
export const RELEASE_BEFORE_BROWSER_SIGN_IN = '0.1.0';
// ---------------------------------------------------------------------------
// Where the pages and the three new server addresses live
// ---------------------------------------------------------------------------
/**
* Where the sign-in page is served from in production.
*
* It has to be this name rather than api.pinloop.ai, even though one Fly
* machine answers both: the login service will only send a browser back to an
* address on its allowed list, and that list names https://pinloop.ai/auth/callback
* (specs/feature-browser-sign-in.md, the recorded settings of 2026-08-19).
*/
export const SIGN_IN_SITE_URL = 'https://pinloop.ai';
/**
* Environment variable overriding where the sign-in page lives. Unset in
* production; the tests point it at the server they started on this machine,
* the same way PINLOOP_SERVER_URL in src/cli/paths.ts points the command at a
* server.
*/
export const SIGN_IN_SITE_URL_ENV_VAR = 'PINLOOP_SIGN_IN_URL';
/** The page a person signs in on: Google, GitHub, or a box for an email address. */
export const SIGN_IN_PAGE_PATH = '/login';
/**
* The page the login service sends the browser back to once Google, GitHub or
* the emailed code has proved who the person is. Its script is what delivers
* the pass to the waiting terminal.
*/
export const CALLBACK_PAGE_PATH = '/auth/callback';
/** Asks Pinloop to email a 6-digit code to one address. Nobody is signed in yet. */
export const SEND_CODE_PATH = '/auth/send-code';
/** Hands back a pass when the 6-digit code matches. Nobody is signed in yet. */
export const VERIFY_CODE_PATH = '/auth/verify-code';
/**
* Parks a pass in the server's memory under a short code, for the case where
* the browser cannot reach the terminal. Nobody is signed in yet.
*/
export const HANDOFF_PATH = '/auth/handoff';
/** Trades that short code for the pass, once. Nobody is signed in yet. */
export const HANDOFF_TRADE_PATH = '/auth/handoff/trade';
/**
* The three addresses this feature adds that answer a caller who has not signed
* in. Storing a held pass and trading it back are one address for the purpose
* of counting requests, because they are two halves of the same fallback.
*/
export const SIGN_IN_ADDRESSES: readonly string[] = Object.freeze([
SEND_CODE_PATH,
VERIFY_CODE_PATH,
HANDOFF_PATH,
]);
// ---------------------------------------------------------------------------
// The listener inside the terminal, and what the browser sends it
// ---------------------------------------------------------------------------
/** The one address the small web server inside `pinloop login` answers. */
export const LOOPBACK_CALLBACK_PATH = '/callback';
/** The name in the printed web address that carries the port being listened on. */
export const PORT_PARAMETER = 'port';
/**
* The name in the printed web address that carries the one-time secret the
* terminal invented when it started listening.
*
* The delivery has to carry the same secret back in its body. Without it, a
* page still open from a different sign-in could hand a pass to the wrong
* terminal, and anything else running on the machine could hand it one too.
*/
export const SECRET_PARAMETER = 'secret';
/**
* What the page's script sends to the terminal, as JSON.
*
* The email address rides along rather than being read out of the pass. The
* page already has it — the login service hands the account back with the
* sign-in — and it is used for one thing only, the printed confirmation line,
* never for deciding who anybody is. Who the caller is, is decided by the
* server, from the pass, on every later request.
*/
export type DeliveredPass = {
/** The one-time secret out of the printed address. */
secret: string;
/** The pass itself: what proves to the server who is running a command. */
access_token: string;
/** The renewal token, which buys the next pass when this one runs out. */
refresh_token?: string;
/** The address that signed in, for the confirmation line and nothing else. */
email?: string;
};
/** How long `pinloop login` waits for a browser before it gives up: 10 minutes. */
export const WAIT_FOR_SIGN_IN_MS = 10 * 60 * 1000;
/** How long a short code shown by the page can be traded for the pass: 10 minutes. */
export const HANDOFF_CODE_LIFETIME_MS = 10 * 60 * 1000;
/** How many digits the code Pinloop emails has. The live login service is set to this. */
export const EMAILED_CODE_DIGITS = 6;
// ---------------------------------------------------------------------------
// Every sentence this feature prints, word for word
// ---------------------------------------------------------------------------
/**
* The first line `pinloop login` prints, with the web address on its own
* indented line under it.
*
* The address is always printed, even on a machine where the command also opens
* the browser itself, because the person running the command is often a coding
* agent and the human does the browser part on their own screen.
*/
export const OPEN_THIS_ADDRESS_LINE = 'open this address in your browser to sign in:';
/** How far the printed web address is indented under the line above it. */
export const ADDRESS_INDENT = ' ';
/**
* The line under the address, for the case where the browser is on a different
* machine from the terminal and cannot reach it.
*/
export const PASTE_THE_CODE_LINE = 'if the page shows you a code, paste it here:';
/** The one line printed when nothing was delivered inside WAIT_FOR_SIGN_IN_MS. */
export const GAVE_UP_LINE =
'the sign-in did not finish within 10 minutes. Run "pinloop login" to try again.';
/**
* The one line a finished sign-in prints. It is the line `pinloop login` has
* printed since slice 1, unchanged: the browser is a new way of proving who
* somebody is, and nothing about what the command does afterwards changes.
*
* Andrew's ruling (2026-08-22): no mention of a file, a path, or "this
* machine" — that is implementation detail a consumer product does not
* surface. The email address is kept because it confirms which account just
* got signed in; everything about where the pass went is dropped.
*/
export function loggedInLine(email: string): string {
return `logged in as ${email}`;
}
/** The one line `pinloop logout` prints when it deleted a saved login. */
export function signedOutLine(): string {
return 'logout successful';
}
/** The one line `pinloop logout` prints when there was nothing to delete. */
export const NO_LOGIN_SAVED_LINE = 'not logged in';
/**
* What somebody typing the old password sign-in is told.
*
* A copy of the command from before this release never reaches this sentence —
* the server refuses it by version and tells it to install again. This sentence
* is for a person who has the new copy and typed the flags from memory or from
* a saved note.
*/
export const PASSWORD_FLAGS_REFUSAL =
'pinloop login no longer takes --email or --password. ' +
'Run "pinloop login" and finish signing in in the browser.';
+110
View File
@@ -0,0 +1,110 @@
/**
* The short instruction file a coding agent saves on the person's own computer.
*
* The format is the one published at agentskills.io: a folder holding one file
* called SKILL.md. Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, VS
* Code and about forty other tools read it, and each of them looks in a
* different folder for it. Pinloop cannot tell which tool is running, so
* `pinloop skill` prints this text and the agent writes it wherever its own tool
* looks. Pinloop never writes a file onto anybody's computer.
*
* Two rules hold this text down, and both are checked by
* src/shared/skill-file.test.ts.
*
* It names no Pinloop command except `pinloop guide` and `pinloop message`. A
* copy of this text saved in October is still sitting in somebody's skills
* folder in March, and by then a command name or an option name written here may
* describe something that has moved on. The full instructions ship inside the
* installed program instead, so they always describe the copy of Pinloop the
* person actually has.
*
* `pinloop message` was allowed in on 2026-08-20, on Andrew's call
* (specs/feature-messages.md, AGREED that date), so that a person whose command
* has just failed has a way to reach the person who can fix it without reading
* the full instructions first. The consequence is written down in that spec:
* from the day it ships, `pinloop message` can never be renamed, because copies
* of this text naming it sit on other people's computers and nothing ever
* updates them.
*
* It ends by telling the agent to run `pinloop guide --skill <this version>` and
* read all of it. That is the one thing that makes the version comparison
* happen every time this file is used, so an agent holding a copy from months
* ago finds out that it is holding one.
*
* SKILL_VERSION is raised by hand, by one, whenever SKILL_TEXT changes. Nothing
* raises it automatically, because a change of wording that does not matter
* would then make every saved copy in the world announce itself as out of date.
*
* Every sentence below is Andrew's, approved 2026-08-19, and replaced the
* placeholder draft shipped earlier the same day. The line breaks are hand
* wrapping at about 80 columns and carry no meaning; the words themselves are
* not to be edited without him.
*/
/** The version of the text below. Raised by hand when the text changes. */
export const SKILL_VERSION = 4;
/**
* The text `pinloop skill` prints and an agent saves.
*
* The closing sentence is built from SKILL_VERSION rather than typed, so the
* number in the text and the number in the code can never drift apart.
*/
export const SKILL_TEXT = `Pinloop searches job postings from a terminal.
Pinloop holds a large collection of real job postings which is updated ~hourly
with fresh postings. It can also store plain text documents (.md, .txt) and the
user's PDF resume. It can use these to make AI calls which judge job postings
for the user, but this can also serve as an organized store of information to
help you, the coding agent, assist the user with their job search.
The person you are working with might have a variety of goals, specific OR
open-ended, for their job search, and your role is to help them achieve those
goals, using the full extent of what Pinloop offers (as much as is helpful).
You should act as an "interpreter" or "translator" of user requests/goals into
Pinloop commands which fulfill what the user desires. This also means you
should translate Pinloop outputs/prints such that the user understands what is
going on. Every command prints lines a person can read, and the commands that
hand back postings will print machine-readable rows instead when you ask for
them, so the output of one command can be fed straight into the next one.
Assume the person does not know what Pinloop can and cannot do for them. They
will not ask for its features by name, and they should not have to. When they
tell you anything about their job search — the kind of work they want, a
company they care about, that they keep missing new postings, that they want a
second opinion on which postings are worth their time — recognize when
something Pinloop offers serves that, and offer it at that moment in plain
words: what you would do, and what they would get back. When a request is
open-ended, make a call on how to serve it and say what you chose, rather than
handing back a list of options. When they expect something Pinloop does not do,
such as filling in or submitting an application, say so plainly rather than
letting them find out later.
Treat the person as a capable adult running their own job search. The decisions
are theirs — where to apply, what to spend their time on — and your job is to
put accurate information in front of them, not to decide for them or to inflate
their prospects. A job search is stressful, so be straight without being harsh:
report what Pinloop actually returned, including when a search comes back thin
or a judgment on a posting is negative, because an honest reading of a weak
match is worth more to them than encouragement. Never tell the person something
happened without having read the output that shows it happened, and when you
are not sure what happened, say so and check rather than guessing.
This file is short on purpose, and it names one command. The set of commands
changes, and a copy of this file saved months ago would be describing a Pinloop
that has moved on. The full instructions ship inside the program on this
machine, so they always describe the commands this machine actually has.
One command is named here, and only one, because a person whose Pinloop command
just failed should not have to read the full instructions first to find out how
to tell anybody. If something goes wrong — a command fails, an answer looks
wrong, something the person expected is missing — run pinloop message followed
by what you want to say, and Andrew, who builds Pinloop, will read it and answer
you in the same place. Write the draft yourself, show the person the exact words
you are about to send, and send only after they say yes. When a line appears
saying an answer from Andrew is waiting, run pinloop message and give the person
his answer word for word.
Before you run anything else for this person, run pinloop guide --skill ${SKILL_VERSION} and
read all of it, so you know what you are able to offer them.
`;
+56
View File
@@ -0,0 +1,56 @@
/**
* The four words a verdict may be, and the two small things every side of the
* product does with them.
*
* A verdict is the model's own word about one job posting and one person: `no`,
* `weak`, `fair` or `strong`, worst to best. The order matters, because a run
* can be told to keep only postings at or above one of the four words, so the
* list below is a scale rather than a set.
*
* These four words are written down once, here, and everything else reads them
* from here. Three different places need them and they must never disagree.
* The file that builds the model request puts the four words in the request as
* the only answers the model may give, and refuses an answer outside them. The
* judge run and the judgment listing check a typed verdict against them. And
* the command line prints a line for every posting a keep word dropped, which
* means the command line needs the wording of that line too — `dropReason`
* below — without needing anything else the judge run does.
*
* That last point is why this file sits under src/shared/ rather than inside
* the judge run. `pinloop judge --keep fair` prints "dropped <id>: judged weak,
* below fair" on the caller's own machine, from a one-line function. Before
* this file existed that one line lived in src/core/judge.ts, so the command
* line imported the whole judge run to get it, and through the judge run it
* imported the model provider, the search, the fetch, the profile reader and
* the embedder — about a dozen files of server code that the published
* `pinloop` package has no use for and must not carry.
*
* Nothing in this file opens a connection, reads a credential, calls a model or
* touches the filesystem. It is four words and two functions over them.
*/
/**
* The four words a verdict may be, worst to best.
*
* Growing or reordering this list changes what the model is allowed to answer,
* what `--keep` compares against, and what `pinloop judgment put` will store,
* all at once. It is a spec change (specs/feature-judge.md), never a tidy-up.
*/
export const VERDICTS = ['no', 'weak', 'fair', 'strong'] as const;
/** One of the four words, as a type. */
export type Verdict = (typeof VERDICTS)[number];
/**
* Why a judge step in a routine, or a judge run in a terminal, left a posting
* out: the verdict it was given and the word the run was keeping at, so a person
* reading the report sees both the judgement and the rule that acted on it.
*/
export function dropReason(verdict: string, keep: string): string {
return `judged ${verdict}, below ${keep}`;
}
/** Where a verdict sits on the four-word scale; higher is better. */
export function rankOf(verdict: string): number {
return VERDICTS.indexOf(verdict as Verdict);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Reading and comparing the version numbers the installed `pinloop` command and
* the server tell each other about.
*
* Why this file exists, and why it sits in src/shared rather than on one side
* or the other. The installed command sends its own version number to the
* server on every request. The server sends two numbers back on every answer:
* the newest version of the command that has been published, and the oldest
* version the server will still answer at all. Both sides then have to decide
* whether one version number is below another. The server decides it to work
* out whether to refuse the request outright. The command decides it to work
* out whether to print the line saying a newer copy exists. If the two pieces
* of code did that differently, a copy could be refused by the server while
* being told nothing at all by itself, so the comparison is written once here
* and imported by both.
*
* What breaks silently if this file is wrong. Version numbers look like text
* and are not text. Compared as text, "0.9.0" sorts after "0.10.0", because the
* character "9" comes after the character "1". A comparison that made that
* mistake would tell a person running 0.10.0 to install 0.9.0, and the day the
* oldest answered version passed 0.9.0 it would cut that person off while they
* were running the newest copy in existence. Nothing would fail anywhere; the
* server would simply stop answering. The other silent failure is answering
* "these are the same version" for text that is not a version number at all,
* because "the same version" is exactly what an up-to-date copy looks like, so
* an unreadable number would be read as up to date forever. That is why
* compareVersions throws rather than guessing, and why isVersion exists to give
* a plain yes or no about a piece of text before anybody compares it.
*
* This file touches nothing outside itself: no network, no database, no money.
*/
/**
* True when `text` is three whole numbers separated by dots, and nothing else.
*
* Anything else is false: an empty string, "1.2", "1.2.3.4", "v1.2.3",
* "1.2.3-beta", a number with a space in front of it. The server needs this
* answer because a version header holding text of any other shape has to be
* treated exactly like a request that carried no version header at all.
*/
export function isVersion(text: string): boolean {
return /^\d+\.\d+\.\d+$/.test(text);
}
/**
* Compares two version numbers and returns a negative number when `a` is older
* than `b`, zero when they are the same version, and a positive number when `a`
* is newer than `b`.
*
* Only the sign of the answer means anything, which is what lets this be handed
* straight to a sort. Each of the three parts is read as a number rather than
* as text, and the first part decides before the second is looked at, so 1.0.0
* is newer than 0.99.99 and 0.1.20 is newer than 0.1.3.
*
* Text that is not a version number throws instead of being given an answer.
*/
export function compareVersions(a: string, b: string): number {
const left = partsOf(a);
const right = partsOf(b);
for (let index = 0; index < 3; index += 1) {
const difference = left[index]! - right[index]!;
if (difference !== 0) return difference;
}
return 0;
}
/** The three whole numbers inside a version number, or a refusal to read it. */
function partsOf(text: string): number[] {
if (!isVersion(text)) {
throw new Error(
`${JSON.stringify(text)} is not a version number of the form 1.2.3. This is a ` +
'Pinloop problem, not something you did. Run `pinloop message` and describe ' +
'what you were doing when it happened.',
);
}
return text.split('.').map((part) => Number(part));
}
+250
View File
@@ -0,0 +1,250 @@
/**
* What `pinloop welcome` prints.
*
* `pinloop welcome` is the first thing a coding agent runs. It prints the same
* text whether or not anybody is signed in, and it contacts no server at all:
* every word of it ships inside the installed program. It is written for the
* agent, not for the person.
*
* It prints five parts, always in this order:
*
* 1. WHAT_PINLOOP_IS one sentence saying what Pinloop is and who it is
* for, so the agent can explain it in its own words.
* 2. SKILL_PART the short instruction file's text, with a line
* telling the agent to save it into its own skills
* folder now.
* 3. HOSTING_PARAGRAPHS what a good host does with the person in front of
* it.
* 4. FIRST_STEPS_OPENING the paragraph that introduces the ordered steps.
* 5. FIRST_STEPS the ordered first steps themselves.
*
* The steps are records rather than lines of prose. Each one carries a `kind`
* saying what the step is for, an `explanation` holding the step's words, and an
* optional `ask` for a step whose request to the person is a separate closing
* sentence. The order of the kinds is fixed and is checked by
* src/shared/welcome.test.ts: the full instructions are read first, then the
* person is signed in, then given the peek, then asked for their resume, then
* shown Pinloop judging the peek's postings against that resume, and then told
* what they might do next. None of the six steps shipping today carries a
* separate `ask`, because in Andrew's approved text every request to the person
* sits inside the step's own paragraph. The field and printStep's ask-last
* behaviour stay because later wording may use them again.
*
* `pinloop login` used to end by printing a second block of onboarding text: a
* conditional opening sentence, the email address, and the steps that remained.
* Andrew removed it on 2026-08-19. Somebody who opens a second terminal and
* signs in again got a paragraph of instructions printed at them, which reads
* badly, and an agent that ran the login command itself already knows the person
* just signed in, so the paragraph told it nothing it did not have. `pinloop
* login` now prints only the plain confirmation of the sign-in. Nothing in this
* file is printed on sign-in any more, and src/shared/welcome.test.ts fails if
* that block comes back.
*
* Every printed sentence below is Andrew's, approved 2026-08-19, and replaced
* the placeholder draft shipped earlier the same day. The line breaks are hand
* wrapping at about 80 columns and carry no meaning; the words themselves are
* not to be edited without him.
*
* Amended 2026-08-22 (specs/feature-welcome-first-steps.md), every word of the
* amendment approved by Andrew that day: the four ordered steps became six.
* The 'profile' step, which gets the person's resume stored, and the 'judge'
* step, which shows Pinloop judging the peek's postings against that resume,
* are new. The 'closing' step was replaced. The paragraph introducing the steps
* gained the sentence telling the agent that the person may leave the steps at
* any point, and the 'guide' step gained the announcement the agent makes to
* the person before anything else happens.
*/
import { SKILL_TEXT } from './skill-file.ts';
/** One step of the ordered first steps. */
export type WelcomeStep = {
/**
* What the step is for: 'guide', 'login', 'peek', 'profile', 'judge',
* 'closing', and more later.
*/
kind: string;
/** Why the agent is doing this step, and what doing it looks like. */
explanation: string;
/** The one sentence that asks the person for something, when there is one. */
ask?: string;
};
/** Part one: what Pinloop is and who it is for. */
export const WHAT_PINLOOP_IS = `Pinloop is a job search tool driven from a terminal, and it is built for people
who already work with a coding agent like you.`;
/** Part two: the instruction file's text, and the line telling the agent to save it. */
export const SKILL_PART = `Save the text between the lines below into your own skills folder now, as a file
called SKILL.md inside a folder of its own. Save it before you do anything else,
because it is what tells you to come back and read the full instructions later.
----------------------------------------------------------------------
${SKILL_TEXT.trim()}
----------------------------------------------------------------------`;
/** Part three: how to look after the person in front of you. */
export const HOSTING_PARAGRAPHS = `Explain what you are about to do before you do it, in one or two plain
sentences, and say what it will cost the person in time or in effort. When a
request is open-ended, make a call on how to serve it with Pinloop and say what
you chose, rather than handing back a list of options.`;
/**
* Part four: the paragraph that introduces the ordered steps.
*
* It holds the rule about never ending a turn without giving the person
* something to say next, which is why it is a paragraph rather than the one
* short line it used to be.
*/
export const FIRST_STEPS_OPENING = `For the following first steps, don't end a turn without giving the person
something they can say next: if you have just shown them something, say what
you can do with it, and if you are waiting on them, say exactly what you need.
The person can leave these steps at any point: if they say they want to skip
the onboarding, stop the steps right there and go where they point. The first
steps, in order:`;
/** Part five: the ordered first steps, printed once by `pinloop welcome`. */
export const FIRST_STEPS: readonly WelcomeStep[] = Object.freeze([
{
kind: 'guide',
explanation: `Before anything else, tell the person this, in these words or close to them:
"We'll start the Pinloop onboarding now (~10 minutes) to get you set up. It's
just a back and forth conversation, and nothing gets run without you knowing.
If at any point you want to skip it, just let me know."
Then read Pinloop's full instructions before you run any other command: run
pinloop guide and read all of it. It works signed out. It covers every command,
every option, the shape of the machine-readable output, the limits and worked
examples, and it is generated from the copy of Pinloop installed on this
machine, so it matches the commands this machine actually has. This costs the
person nothing — read it yourself, without making them wait on a summary of it,
so that everything you run from here on is a command you actually understand.`,
},
{
kind: 'login',
explanation: `Nothing else works until the person has a Pinloop account. Direct the user to
run the login command(s) the guide names. Tell them what will happen when they
run it, and ask them to sign in. Be very concise here.`,
},
{
kind: 'peek',
explanation: `Once the person has signed in, before you ask the person for anything else,
show them that Pinloop holds real jobs for them. This is just a throwaway peek,
not the description Pinloop will work from: nothing from their answer is stored
anywhere or used by Pinloop long-term. Say that it is a quick look, so nobody
thinks that this sentence has to stand in for their whole job search. Be
concise here too. Ask them for a job title, or a few words describing the work
they do. Decide whether a semantic search or a normal keyword search would be
best based on what the user says. Then search them up and show them eight or
ten *recent* postings, each with the company, the title, the location and how
long ago it was posted.`,
},
{
kind: 'profile',
explanation: `After the peek, get the person's resume into Pinloop. This is the one quick
step that lets Pinloop judge postings against them properly, so frame it that
way: a resume is all it needs to get started, and there's more they can add
later, which you'll come back to at the end. Don't ask them for anything else
here, and don't interview them about what they want. Offer the three ways this
can happen in one sentence: they can drag the file into the terminal, or tell
you the file path, or you can find it on their computer if they want you to.
Never go looking on their computer unless they say yes. Once you have the file,
store it with pinloop profile put and confirm it's in. Be concise here too.`,
},
{
kind: 'judge',
explanation: `Once the resume is stored, show the person what Pinloop actually does: take the
postings from the peek and judge them against the resume they just gave you.
Tell them what's about to happen in a sentence first, and say roughly how long
each part will take before you start it, so they're not sitting there wondering
if anything is happening. Run pinloop judge --quick over all of the peek's
postings, which screens each one on its plain facts in a single pass and
usually comes back within a minute, and show them the verdicts. Then take the
one that screened strongest and judge it fully with pinloop judge, which reads
the whole posting against their resume and explains itself, taking about a
minute or so, and show them that verdict with its reasoning. This is the first
real judgment they've seen, so don't bury it: put the verdict and the reasoning
in front of them plainly and let them read it before you say anything else.
Don't tell them how much of their usage this spent or what they have left;
that comes at the close, read from their account.`,
},
{
kind: 'closing',
explanation: `To close the first steps, after the person has read the verdict, give them the
lay of the land in plain words so they know what is in the room, then let them
pick. Say exactly this:
"That verdict was made against your resume. Anything else you tell me about
what you want (e.g., a written description of the job you're after, or the
things that would rule a posting out for you) goes into your profile and makes
every future verdict sharper. Tell me whenever.
Finding jobs from here is open ended. Describe what you want in plain English,
a job title, a paragraph about the work you're looking for, a company, a list
of conditions, and I'll work out how to get it out of Pinloop.
Judging comes in two sizes. A quick screen takes a big batch of postings at
once and judges each one on its plain facts, the title, the company, the
location and so on, without reading the full job description, so it covers a
lot of ground quickly. A full judgment reads the whole posting against your
profile and gives you the reasoning, like the one you just saw.
Anything Pinloop finds can go into a tab, which is just a named list you keep
postings in, so the good ones don't get lost.
Once we've worked out a way of finding and judging jobs that you like, I can
save it as a routine and run it again with one command. A schedule runs that
routine every few hours on its own. A watch runs it only over the postings that
have arrived since the last time it looked. All of that lives on Pinloop's
servers, so it keeps going while you're away and the results are waiting when
you come back.
If something is wrong or you want to ask for anything, I can run pinloop
message to tell Andrew about it (he builds Pinloop). He'd love to get your
feedback, and you'll get his response straight through this CLI.
I've read the full instructions for Pinloop, so if you want to explore anything
further, just say so and we'll go deeper."
Then tell the person where they stand: read how much of this month's usage
they have left back from their account rather than working it out, and say it
plainly. Then say: "If you upgrade, you get over 5,000 quickly judged postings
a month, or 400 full judgments, plus unlimited plain-English searches (if
you're not spamming hundreds per minute). I can run pinloop upgrade if you want
to learn more."
Don't name a price yourself. Leave all of this as things they can pick from,
not a list to follow, and go where they point.`,
},
]);
/** One step as it appears on the terminal: the explanation, then the ask last. */
export function printStep(step: WelcomeStep): string {
const explanation = step.explanation.trim();
const ask = step.ask?.trim() ?? '';
return ask === '' ? explanation : `${explanation}\n\n${ask}`;
}
/** The parts of the welcome, each one replaceable so a test can find the slot. */
export type WelcomeParts = {
whatPinloopIs?: string;
skillPart?: string;
hostingParagraphs?: string;
firstStepsOpening?: string;
firstSteps?: readonly WelcomeStep[];
};
/** The whole of what `pinloop welcome` prints: five parts, in order. */
export function printWelcome(parts: WelcomeParts = {}): string {
const whatPinloopIs = parts.whatPinloopIs ?? WHAT_PINLOOP_IS;
const skillPart = parts.skillPart ?? SKILL_PART;
const hostingParagraphs = parts.hostingParagraphs ?? HOSTING_PARAGRAPHS;
const firstStepsOpening = parts.firstStepsOpening ?? FIRST_STEPS_OPENING;
const firstSteps = parts.firstSteps ?? FIRST_STEPS;
const blocks: string[] = [whatPinloopIs.trim(), skillPart.trim()];
if (hostingParagraphs.trim() !== '') blocks.push(hostingParagraphs.trim());
blocks.push(firstStepsOpening.trim());
for (const step of firstSteps) blocks.push(printStep(step));
return `${blocks.filter((block) => block !== '').join('\n\n')}\n`;
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022"
],
"module": "ESNext",
"moduleResolution": "bundler",
"types": [
"node"
],
"strict": true,
"noUncheckedIndexedAccess": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": false,
"declaration": false
},
"include": [
"src"
]
}