Skip to content
30 changes: 28 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ socket in the background, so a daemon that comes up later is picked up with no
restart. A hangar unusable for 300 s — unreachable, or answering another protocol —
sends one `hangar_offline` push per episode, none on recovery; the cockpit never pushes for being offline.

How many agents the hangar runs at once is capped by `CC_MOBILE_HANGAR_CAP`; see **Launch API**.

## Commands

```bash
Expand Down Expand Up @@ -502,7 +504,7 @@ that can refuse a keystroke is a worse failure than a missing line.

`POST /api/launch` (`server/launch.ts`) dispatches an Obsidian task card onto
the **hangar** without a phone: `{cwd, cardPath, vault, project, profileId?}` →
`201 {sessionId, claudeUuid}`. `cardPath` is relative to the vault, `vault` must
`201 {sessionId, claudeUuid}`, or `202 {queued: true, position}` when the hangar is at its cap (below). `cardPath` is relative to the vault, `vault` must
equal the basename of `CC_MOBILE_VAULT_ROOT`, and `project` matches
`[A-Za-z0-9._-]+` without a leading dot. It is the same `terminal_create` +
`terminal_send` path the WS uses, so `CC_MOBILE_ALLOWED_ROOTS` and the profile
Expand All @@ -525,7 +527,9 @@ path that does not resolve is `404 card_not_found`; one whose resolved path
root that then cannot be read (a directory, mode 000) is `404 card_not_found`; a
profile of a non-claude kind is `400 unsupported_kind`. The request's own
`cwd` is checked next, with the terminal codes (`400 invalid_cwd`,
`403 path_not_allowed`), then its profile (`400 unknown_profile`). When `cwd` is
`403 path_not_allowed`), then its profile (`400 unknown_profile`). `409 already_queued` and
`503 hangar_offline` come after `unknown_profile` and before the worktree checks.
When `cwd` is
inside a git repo the worktree checks follow, all before git writes anything: a
repo git refuses to read (dubious ownership, a broken `.git`) is
`500 worktree_failed`; a repo top level outside every allowed root, or a
Expand Down Expand Up @@ -635,6 +639,22 @@ on the new pane before sending, writing into the same replay buffer the WS uses:
a phone that connects later replays the turn. Its audit line has
`device: "launch-api"`.

The hangar runs a bounded number of agents at once. The cap is
`CC_MOBILE_HANGAR_CAP` when set (blank counts as unset; a value that is not a
positive integer stops the server at startup), else performance cores − 2 with a
minimum of 1, else 4 when the core count is unknown. Occupancy is the hangar's
own `agent.list` plus launches admitted but not yet finished, so an agent the
user started by hand counts. Past the cap a launch is not refused but queued,
first in first out: the reply is `202 {queued: true, position}` and the
request's checks have already passed. A card already waiting, or admitted and still starting, answers
`409 already_queued`, and a hangar that cannot be asked how full it is answers
`503 hangar_offline` rather than guess it empty — never over-admit. When a slot
frees, the next launch is checked again against the live occupancy before it
starts; a failure at that point has no caller left to tell and is a log line
only, except that a dequeued launch whose prompt failed still writes today's
`prompt_send` audit line, `failed`, or `rejected` when the pane was busy (only failures before the prompt are log-only). The queue lives in memory, so a restart empties the queue and the
caller dispatches again.

## Cards API

`GET /api/cards` (`server/cards.ts`) lists the obw task cards an inbox can
Expand Down Expand Up @@ -664,6 +684,12 @@ archiving a card is obw's, whose dependency check lives in its pm skill. The
route needs no token, since it starts nothing; the root gate still runs in front
of it.

Each card also carries `queued`, true when its launch waits for a hangar slot or was admitted and is still starting,
and the reply carries `hangar: {cap, occupied, queued}`. `hangar.queued` counts only
launches still waiting for a slot; one that is starting is counted in `occupied`. `occupied` is `null`,
not 0, when the hangar cannot be asked: offline is unknown, not empty. The GET
reads this state and starts nothing, queued launches included.

## Security Constraints

- cc-mobile generates no agent settings of its own: the argv it builds carries no
Expand Down
81 changes: 79 additions & 2 deletions server/__tests__/cards-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCardsPlugin } from "../cards";
import { createCardsPlugin, type HangarView } from "../cards";
import { cardKey } from "../launch-queue";
import { testServerConfig } from "./ws-harness";

let tmp: string;
Expand Down Expand Up @@ -47,9 +48,10 @@ afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});

async function get() {
async function get(hangar?: HangarView | null) {
const app = createCardsPlugin({
config: testServerConfig,
...(hangar !== undefined ? { hangar } : {}),
workspaceRoot: workspace,
obsidianConfigPath: obsidianConfig,
launchesDir,
Expand All @@ -69,10 +71,12 @@ interface CardsBody {
status: string | null;
priority: string | null;
blockedBy: string[];
queued: boolean;
dispatchable: boolean;
}[];
}[];
skipped: { repo: string; reason: string }[];
hangar: { cap: number; occupied: number | null; queued: number } | null;
}

describe("GET /api/cards", () => {
Expand All @@ -99,6 +103,7 @@ describe("GET /api/cards", () => {
status: "todo",
priority: "high",
blockedBy: ["[[b]]"],
queued: false,
dispatchable: false,
},
],
Expand Down Expand Up @@ -239,6 +244,78 @@ describe("GET /api/cards", () => {
expect(body.skipped).toEqual([{ repo: join(workspace, "p"), reason: "vault_not_found" }]);
});

test("no hangar view reports hangar: null", async () => {
const { body } = await get();
expect(body.hangar).toBeNull();
});

test("a hangar view reports its cap, occupancy and queue length", async () => {
const hangar = { has: () => false, status: async () => ({ cap: 4, occupied: 2, queued: 0 }) };
const { body } = await get(hangar);
expect(body.hangar).toEqual({ cap: 4, occupied: 2, queued: 0 });
});

describe("queued cards", () => {
const viewOf = (...keys: string[]): HangarView => ({
has: (key) => keys.includes(key),
status: async () => ({ cap: 1, occupied: 1, queued: keys.length }),
});
const verdicts = (body: CardsBody) =>
Object.fromEntries(
body.projects[0].cards.map((c) => [
c.cardPath.split("/").pop(),
{ queued: c.queued, dispatchable: c.dispatchable },
]),
);

test("a waiting card is queued and not dispatchable; its sibling is unaffected", async () => {
repo("p", "vault: obsidian\npm:\n project: p\n");
card("p", "a.md", "type: task\nstatus: todo");
card("p", "b.md", "type: task\nstatus: todo");
const { body } = await get(viewOf(cardKey("obsidian", "pm/p/tasks/a.md")));
expect(verdicts(body)).toEqual({
"a.md": { queued: true, dispatchable: false },
"b.md": { queued: false, dispatchable: true },
});
});

test("a key spelled with a doubled slash names the same card", async () => {
repo("p", "vault: obsidian\npm:\n project: p\n");
card("p", "a.md", "type: task\nstatus: todo");
const { body } = await get(viewOf(cardKey("obsidian", "pm//p/tasks/a.md")));
expect(verdicts(body)["a.md"].queued).toBe(true);
});

test("a binding written with a doubled slash still binds its card", async () => {
repo("p", "vault: obsidian\npm:\n project: p\n");
card("p", "a.md", "type: task\nstatus: todo");
write(
join(launchesDir, "u.json"),
JSON.stringify({ vault: "obsidian", cardPath: "pm//p/tasks/a.md" }),
);
const { body } = await get(viewOf());
expect(verdicts(body)["a.md"].dispatchable).toBe(false);
});

test("a blocked card that is queued is queued and not dispatchable", async () => {
repo("p", "vault: obsidian\npm:\n project: p\n");
card("p", "a.md", 'type: task\nstatus: todo\nblocked_by: ["[[x]]"]');
const { body } = await get(viewOf(cardKey("obsidian", "pm/p/tasks/a.md")));
expect(verdicts(body)["a.md"]).toEqual({ queued: true, dispatchable: false });
});

test("a bound card that left the queue is bound, not queued, not dispatchable", async () => {
repo("p", "vault: obsidian\npm:\n project: p\n");
card("p", "a.md", "type: task\nstatus: todo");
write(
join(launchesDir, "u.json"),
JSON.stringify({ vault: "obsidian", cardPath: "pm/p/tasks/a.md" }),
);
const { body } = await get(viewOf());
expect(verdicts(body)["a.md"]).toEqual({ queued: false, dispatchable: false });
});
});

test("reading writes nothing anywhere under the workspace, the vault or the launches dir", async () => {
repo("p", "vault: obsidian\npm:\n project: p\n");
card("p", "a.md", "type: task\nstatus: todo\nblocked_by: []");
Expand Down
81 changes: 81 additions & 0 deletions server/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("parseServerConfig", () => {
delete process.env.CC_MOBILE_ALLOWED_ROOTS;
delete process.env.BASE_PATH;
delete process.env.CC_MOBILE_VAULT_ROOT;
delete process.env.CC_MOBILE_HANGAR_CAP;
const result = parseServerConfig(["node", "index.ts"]);
expect(result).toEqual({
port: 3001,
Expand All @@ -30,6 +31,7 @@ describe("parseServerConfig", () => {
hangarSession: null,
launchToken: null,
vaultRoot: null,
hangarCap: 4,
});
cleanup();
});
Expand All @@ -38,6 +40,7 @@ describe("parseServerConfig", () => {
delete process.env.CC_MOBILE_ALLOWED_ROOTS;
delete process.env.BASE_PATH;
delete process.env.CC_MOBILE_VAULT_ROOT;
delete process.env.CC_MOBILE_HANGAR_CAP;
const result = parseServerConfig([
"node",
"index.ts",
Expand All @@ -58,6 +61,7 @@ describe("parseServerConfig", () => {
hangarSession: null,
launchToken: null,
vaultRoot: null,
hangarCap: 4,
});
cleanup();
});
Expand Down Expand Up @@ -168,4 +172,81 @@ describe("parseServerConfig", () => {
expect(() => parseServerConfig(["node", "index.ts"])).toThrow("CC_MOBILE_PUSH_SCOPE");
});
});

describe("hangar cap from performance cores", () => {
const original = process.env.CC_MOBILE_HANGAR_CAP;
afterEach(() => {
if (original === undefined) delete process.env.CC_MOBILE_HANGAR_CAP;
else process.env.CC_MOBILE_HANGAR_CAP = original;
});
const capFor = (readPerfCores?: () => number | null) => {
delete process.env.CC_MOBILE_HANGAR_CAP;
return parseServerConfig(["node", "index.ts"], readPerfCores ? { readPerfCores } : undefined)
.hangarCap;
};

test("6 cores -> 4", () => expect(capFor(() => 6)).toBe(4));
test("10 cores -> 8", () => expect(capFor(() => 10)).toBe(8));
test("3, 2 or 1 cores -> 1", () => {
for (const n of [3, 2, 1]) expect(capFor(() => n)).toBe(1);
});
test("null -> 4", () => expect(capFor(() => null)).toBe(4));
test("a throwing reader -> 4", () =>
expect(
capFor(() => {
throw new Error("x");
}),
).toBe(4));
test("no options -> 4", () => expect(capFor()).toBe(4));
test("reader is called exactly once", () => {
let calls = 0;
capFor(() => {
calls++;
return 6;
});
expect(calls).toBe(1);
});
});

describe("CC_MOBILE_HANGAR_CAP", () => {
const original = process.env.CC_MOBILE_HANGAR_CAP;
afterEach(() => {
if (original === undefined) delete process.env.CC_MOBILE_HANGAR_CAP;
else process.env.CC_MOBILE_HANGAR_CAP = original;
});
const parse = (v: string, readPerfCores: () => number | null = () => 6) => {
process.env.CC_MOBILE_HANGAR_CAP = v;
return parseServerConfig(["node", "index.ts"], { readPerfCores });
};

test("pins the cap and never reads the cores", () => {
let calls = 0;
const cfg = parse("6", () => {
calls++;
return 2;
});
expect(cfg.hangarCap).toBe(6);
expect(calls).toBe(0);
});

test("trims surrounding whitespace", () => expect(parse(" 3 ").hangarCap).toBe(3));
test("accepts two digits", () => expect(parse("12").hangarCap).toBe(12));

test("a malformed value throws naming the variable and the value", () => {
for (const v of ["0", "04", "+4", "4.0", "-1", "abc", "4 4"]) {
expect(() => parse(v)).toThrow(/CC_MOBILE_HANGAR_CAP/);
expect(() => parse(v)).toThrow(v);
}
});

test("blank counts as unset", () => {
let calls = 0;
const cfg = parse(" ", () => {
calls++;
return 6;
});
expect(cfg.hangarCap).toBe(4);
expect(calls).toBe(1);
});
});
});
27 changes: 27 additions & 0 deletions server/__tests__/docs-launch-claims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,31 @@ describe("CLAUDE.md launch docs", () => {
);
expect(flat).toContain("except that `POST /api/launch` answers `503 hangar_unavailable`");
});

test("documents the hangar cap and its queue", () => {
for (const term of [
"CC_MOBILE_HANGAR_CAP",
"202 {queued: true, position}",
"409 already_queued",
"503 hangar_offline",
"hangar: {cap, occupied, queued}",
"agent.list",
"restart empties the queue",
]) {
expect(doc.replace(/\s+/g, " ")).toContain(term);
}
});

test("states how queued, admitted and dequeued launches answer", () => {
const flat = doc.replace(/\s+/g, " ");
expect(flat).toContain("waiting, or admitted and still starting, answers `409 already_queued`");
expect(flat).toContain("waits for a hangar slot or was admitted and is still starting");
expect(flat).toContain("come after `unknown_profile` and before the worktree checks");
expect(flat).toContain(
"still writes today's `prompt_send` audit line, `failed`, or `rejected` when the pane was busy",
);
expect(flat).toContain(
"`hangar.queued` counts only launches still waiting for a slot; one that is starting is counted in `occupied`",
);
});
});
Loading
Loading