@higherdev/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -14
  2. package/dist/index.js +1014 -697
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11,9 +11,10 @@ var __export = (target, all) => {
11
11
 
12
12
  // ../../packages/db/src/enums.ts
13
13
  function asEffort(value) {
14
- return value === "low" || value === "high" ? value : "medium";
14
+ const raw = typeof value === "string" ? value.trim() : "";
15
+ return efforts.includes(raw) ? raw : DEFAULT_EFFORT;
15
16
  }
16
- var ticketStatuses, epicStatuses, runStatuses, runKinds, runEventTypes, agentRoles, providers, efforts, messageRoles, messageToRoles, deliveries, createdBy;
17
+ var ticketStatuses, epicStatuses, runStatuses, runKinds, runEventTypes, agentRoles, providers, efforts, messageRoles, messageToRoles, deliveries, createdBy, DEFAULT_EFFORT;
17
18
  var init_enums = __esm({
18
19
  "../../packages/db/src/enums.ts"() {
19
20
  "use strict";
@@ -43,13 +44,14 @@ var init_enums = __esm({
43
44
  "error",
44
45
  "status"
45
46
  ];
46
- agentRoles = ["orchestrator", "reviewer", "builder", "architect"];
47
+ agentRoles = ["orchestrator", "reviewer", "builder"];
47
48
  providers = ["claude", "codex", "gemini", "grok"];
48
49
  efforts = ["low", "medium", "high"];
49
50
  messageRoles = ["human", "operator", "orchestrator", "reviewer", "builder", "architect"];
50
51
  messageToRoles = [...messageRoles, "all"];
51
52
  deliveries = ["queue", "interrupt"];
52
53
  createdBy = ["human", "orchestrator", "api"];
54
+ DEFAULT_EFFORT = "high";
53
55
  }
54
56
  });
55
57
 
@@ -183,6 +185,8 @@ function auditSummary(actor, action, payload = {}) {
183
185
  return key2 ? `${actor} took over ${key2}` : `${actor} took over a ticket`;
184
186
  case "hand_back":
185
187
  return key2 ? `${actor} handed back ${key2}` : `${actor} handed back a ticket`;
188
+ case "merge_override":
189
+ return key2 ? `${actor} authorized an optional-check merge for ${key2}` : `${actor} authorized an optional-check merge`;
186
190
  case "create":
187
191
  return key2 ? `${actor} created ${key2}` : `${actor} created ${subject(payload)}`;
188
192
  case "update":
@@ -456,6 +460,36 @@ var init_cost = __esm({
456
460
  }
457
461
  });
458
462
 
463
+ // ../../packages/db/src/commands/lifecycle.ts
464
+ var lifecycleCommandRejectionCodes, lifecycleCommandResultCodes, TERMINAL_TICKET_STATUSES, OPTIONAL_CHECK_OVERRIDE_STATUSES, ticketStatusSet, terminalStatusSet, optionalCheckOverrideStatusSet;
465
+ var init_lifecycle = __esm({
466
+ "../../packages/db/src/commands/lifecycle.ts"() {
467
+ "use strict";
468
+ init_enums();
469
+ lifecycleCommandRejectionCodes = [
470
+ "illegal_transition",
471
+ "terminal_target",
472
+ "stale_state",
473
+ "malformed_intent",
474
+ "duplicate_command_identity"
475
+ ];
476
+ lifecycleCommandResultCodes = ["accepted", ...lifecycleCommandRejectionCodes];
477
+ TERMINAL_TICKET_STATUSES = ["merged", "cancelled"];
478
+ OPTIONAL_CHECK_OVERRIDE_STATUSES = ["approved", "needs_decision"];
479
+ ticketStatusSet = new Set(ticketStatuses);
480
+ terminalStatusSet = new Set(TERMINAL_TICKET_STATUSES);
481
+ optionalCheckOverrideStatusSet = new Set(OPTIONAL_CHECK_OVERRIDE_STATUSES);
482
+ }
483
+ });
484
+
485
+ // ../../packages/db/src/commands/index.ts
486
+ var init_commands = __esm({
487
+ "../../packages/db/src/commands/index.ts"() {
488
+ "use strict";
489
+ init_lifecycle();
490
+ }
491
+ });
492
+
459
493
  // ../../packages/db/src/defaults.ts
460
494
  function slugify(name) {
461
495
  const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -467,9 +501,19 @@ function hostAllowsRepo(allowedRepoOwners, repo) {
467
501
  if (!owner) return false;
468
502
  return allowedRepoOwners.some((item) => item.toLowerCase() === owner);
469
503
  }
504
+ var DEFAULT_AGENTS;
470
505
  var init_defaults = __esm({
471
506
  "../../packages/db/src/defaults.ts"() {
472
507
  "use strict";
508
+ init_enums();
509
+ DEFAULT_AGENTS = [
510
+ { role: "orchestrator", provider: "claude", model: "claude-fable-5", display_name: "Orchestrator", effort: DEFAULT_EFFORT },
511
+ { role: "reviewer", provider: "claude", model: "claude-opus-5", display_name: "Reviewer", effort: DEFAULT_EFFORT },
512
+ { role: "builder", provider: "claude", model: "claude-sonnet-5", display_name: "Claude", effort: DEFAULT_EFFORT },
513
+ { role: "builder", provider: "codex", model: "gpt-5.6-sol", display_name: "Codex", effort: DEFAULT_EFFORT },
514
+ { role: "builder", provider: "gemini", model: "gemini-3.7-flash-high", display_name: "Gemini", effort: DEFAULT_EFFORT },
515
+ { role: "builder", provider: "grok", model: "grok-4.6", display_name: "Grok", effort: DEFAULT_EFFORT }
516
+ ];
473
517
  }
474
518
  });
475
519
 
@@ -691,7 +735,7 @@ var init_schemas = __esm({
691
735
  role: z.enum(agentRoles),
692
736
  provider: z.enum(providers),
693
737
  model: z.string().min(1),
694
- effort: z.enum(efforts).default("medium"),
738
+ effort: z.enum(efforts).default(DEFAULT_EFFORT),
695
739
  display_name: z.string().min(1),
696
740
  enabled: z.boolean(),
697
741
  routing_notes: z.string().default(""),
@@ -746,6 +790,7 @@ var init_schemas = __esm({
746
790
  next_review_at: isoDateSchema.nullable(),
747
791
  created_by: z.enum(createdBy),
748
792
  last_error: z.string().nullable(),
793
+ merge_override_optional_checks: z.boolean().default(false),
749
794
  created_at: isoDateSchema,
750
795
  updated_at: isoDateSchema
751
796
  });
@@ -835,7 +880,8 @@ var init_schemas = __esm({
835
880
  "dismiss",
836
881
  "reroute",
837
882
  "takeover",
838
- "hand_back"
883
+ "hand_back",
884
+ "merge_override"
839
885
  ]);
840
886
  auditEventSchema = z.object({
841
887
  id: uuidSchema,
@@ -1036,6 +1082,32 @@ var init_templates = __esm({
1036
1082
  }
1037
1083
  });
1038
1084
 
1085
+ // ../../packages/db/src/tickets.ts
1086
+ function uncertainCreateError(error) {
1087
+ return Boolean(error && (!error.code || /^PGRST00[0-3]$/.test(error.code)));
1088
+ }
1089
+ async function createTicketRecord(db, workspaceId, fields, ticketId) {
1090
+ const requestId = ticketId ?? globalThis.crypto.randomUUID();
1091
+ const args = {
1092
+ p_ticket_id: requestId,
1093
+ p_workspace_id: workspaceId,
1094
+ p_fields: fields
1095
+ };
1096
+ for (let attempt = 0; ; attempt += 1) {
1097
+ try {
1098
+ const result = await db.rpc("create_ticket", args);
1099
+ if (!result.error || attempt === 1 || !uncertainCreateError(result.error)) return result;
1100
+ } catch (error) {
1101
+ if (attempt === 1) throw error;
1102
+ }
1103
+ }
1104
+ }
1105
+ var init_tickets = __esm({
1106
+ "../../packages/db/src/tickets.ts"() {
1107
+ "use strict";
1108
+ }
1109
+ });
1110
+
1039
1111
  // ../../packages/db/src/ticket-writes.ts
1040
1112
  async function workspaceBySlug(db, slug) {
1041
1113
  const { data, error } = await db.from("workspaces").select("*").eq("slug", slug).maybeSingle();
@@ -1134,7 +1206,13 @@ function buildTimeline(input) {
1134
1206
  id: `run:${run5.id}`,
1135
1207
  at,
1136
1208
  kind,
1137
- title: [run5.kind, run5.provider, run5.status, span].filter(Boolean).join(" "),
1209
+ title: [
1210
+ run5.kind,
1211
+ run5.provider,
1212
+ run5.status,
1213
+ run5.reviewed_sha ? `@ ${run5.reviewed_sha.slice(0, 12)}` : "",
1214
+ span
1215
+ ].filter(Boolean).join(" "),
1138
1216
  detail: run5.summary ? clip(run5.summary) : void 0,
1139
1217
  durationMs: runDurationMs(run5, nowIso),
1140
1218
  endedAt: run5.ended_at
@@ -1216,8 +1294,8 @@ function toolDetail(payload) {
1216
1294
  const command = str(input.command || input.cmd);
1217
1295
  const additions = Number(input.additions ?? input.added);
1218
1296
  const deletions = Number(input.deletions ?? input.removed);
1219
- const stat = Number.isFinite(additions) || Number.isFinite(deletions) ? ` +${Number.isFinite(additions) ? additions : 0}/-${Number.isFinite(deletions) ? deletions : 0}` : "";
1220
- if (file) return `${file}${stat}`;
1297
+ const stat2 = Number.isFinite(additions) || Number.isFinite(deletions) ? ` +${Number.isFinite(additions) ? additions : 0}/-${Number.isFinite(deletions) ? deletions : 0}` : "";
1298
+ if (file) return `${file}${stat2}`;
1221
1299
  if (command) return command.length > 80 ? `${command.slice(0, 77)}...` : command;
1222
1300
  return "";
1223
1301
  }
@@ -1388,6 +1466,7 @@ var init_src = __esm({
1388
1466
  init_blocked();
1389
1467
  init_budgets();
1390
1468
  init_cost();
1469
+ init_commands();
1391
1470
  init_defaults();
1392
1471
  init_enums();
1393
1472
  init_epic_progress();
@@ -1399,6 +1478,7 @@ var init_src = __esm({
1399
1478
  init_status();
1400
1479
  init_stuck();
1401
1480
  init_templates();
1481
+ init_tickets();
1402
1482
  init_ticket_writes();
1403
1483
  init_timeline();
1404
1484
  init_transcript();
@@ -1519,6 +1599,119 @@ var init_theme = __esm({
1519
1599
  }
1520
1600
  });
1521
1601
 
1602
+ // src/architect/connection.ts
1603
+ var connection_exports = {};
1604
+ __export(connection_exports, {
1605
+ classifyAuthFile: () => classifyAuthFile,
1606
+ codexConnection: () => codexConnection,
1607
+ codexHome: () => codexHome,
1608
+ connectCodex: () => connectCodex,
1609
+ connectionSummary: () => connectionSummary,
1610
+ howToFix: () => howToFix,
1611
+ onPath: () => onPath
1612
+ });
1613
+ import { execFile, spawn } from "child_process";
1614
+ import { access, readFile as readFile2 } from "fs/promises";
1615
+ import { constants } from "fs";
1616
+ import { homedir as homedir2 } from "os";
1617
+ import path2 from "path";
1618
+ import { promisify } from "util";
1619
+ function codexHome(env = process.env) {
1620
+ return env.CODEX_HOME ?? path2.join(homedir2(), ".codex");
1621
+ }
1622
+ function classifyAuthFile(auth) {
1623
+ if (!auth) return null;
1624
+ const apiKey = typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY.trim() : "";
1625
+ if (apiKey) {
1626
+ return {
1627
+ state: "api_key",
1628
+ detail: "Codex is signed in with an API key, which bills per token."
1629
+ };
1630
+ }
1631
+ const refresh = auth.tokens?.refresh_token;
1632
+ if (auth.auth_mode === "chatgpt" && typeof refresh === "string" && refresh.length > 0) {
1633
+ const accountId = typeof auth.tokens?.account_id === "string" ? auth.tokens.account_id : null;
1634
+ return { state: "connected", accountId, detail: "Codex is connected to a ChatGPT subscription." };
1635
+ }
1636
+ return { state: "signed_out", detail: "Codex is installed but not signed in." };
1637
+ }
1638
+ async function onPath(bin, env = process.env) {
1639
+ const dirs = (env.PATH ?? "").split(path2.delimiter).filter(Boolean);
1640
+ for (const dir of dirs) {
1641
+ try {
1642
+ await access(path2.join(dir, bin), constants.X_OK);
1643
+ return true;
1644
+ } catch {
1645
+ }
1646
+ }
1647
+ return false;
1648
+ }
1649
+ async function codexConnection(env = process.env) {
1650
+ if (!await onPath("codex")) {
1651
+ return {
1652
+ state: "not_installed",
1653
+ detail: "The Codex CLI is not on PATH. Install it, then run `hd connect`."
1654
+ };
1655
+ }
1656
+ let auth = null;
1657
+ try {
1658
+ auth = JSON.parse(await readFile2(path2.join(codexHome(env), "auth.json"), "utf8"));
1659
+ } catch {
1660
+ auth = null;
1661
+ }
1662
+ const fromFile = classifyAuthFile(auth);
1663
+ if (fromFile) return fromFile;
1664
+ try {
1665
+ const { stdout } = await run("codex", ["login", "status"], { timeout: 2e4 });
1666
+ const text = stdout.trim();
1667
+ if (/chatgpt/i.test(text)) {
1668
+ return { state: "connected", accountId: null, detail: text };
1669
+ }
1670
+ if (/api key/i.test(text)) {
1671
+ return { state: "api_key", detail: text };
1672
+ }
1673
+ return { state: "signed_out", detail: text || "Codex is not signed in." };
1674
+ } catch {
1675
+ return { state: "signed_out", detail: "Codex is installed but not signed in." };
1676
+ }
1677
+ }
1678
+ function connectionSummary(connection) {
1679
+ switch (connection.state) {
1680
+ case "connected":
1681
+ return connection.accountId ? `${connection.detail} Account ${connection.accountId}.` : connection.detail;
1682
+ default:
1683
+ return connection.detail;
1684
+ }
1685
+ }
1686
+ function howToFix(connection) {
1687
+ switch (connection.state) {
1688
+ case "connected":
1689
+ return null;
1690
+ case "not_installed":
1691
+ return "Install the Codex CLI, then run `hd connect`.";
1692
+ case "api_key":
1693
+ return "Run `codex logout` then `hd connect` to sign in with the subscription instead.";
1694
+ default:
1695
+ return "Run `hd connect` to sign in through ChatGPT.";
1696
+ }
1697
+ }
1698
+ async function connectCodex() {
1699
+ const code = await new Promise((resolve, reject) => {
1700
+ const child = spawn("codex", ["login"], { stdio: "inherit" });
1701
+ child.on("error", reject);
1702
+ child.on("exit", (status) => resolve(status ?? 1));
1703
+ });
1704
+ if (code !== 0) throw new Error(`codex login exited ${code}`);
1705
+ return codexConnection();
1706
+ }
1707
+ var run;
1708
+ var init_connection = __esm({
1709
+ "src/architect/connection.ts"() {
1710
+ "use strict";
1711
+ run = promisify(execFile);
1712
+ }
1713
+ });
1714
+
1522
1715
  // src/read/queries.ts
1523
1716
  function unwrap(result) {
1524
1717
  if (result.error) fail(result.error.message);
@@ -1803,53 +1996,289 @@ var init_subscribe = __esm({
1803
1996
  }
1804
1997
  });
1805
1998
 
1806
- // ../runner/src/providers.ts
1807
- import { execFile as execFile2, execFileSync } from "child_process";
1808
- import { promisify as promisify2 } from "util";
1809
- function isOnPath(bin) {
1999
+ // src/architect/chatgpt.ts
2000
+ var chatgpt_exports = {};
2001
+ __export(chatgpt_exports, {
2002
+ ChatGptAuthError: () => ChatGptAuthError,
2003
+ MAX_STEPS: () => MAX_STEPS,
2004
+ getAuth: () => getAuth,
2005
+ mergeRefreshed: () => mergeRefreshed,
2006
+ msUntilExpiry: () => msUntilExpiry,
2007
+ newSessionId: () => newSessionId,
2008
+ runAgent: () => runAgent
2009
+ });
2010
+ import { readFileSync, renameSync, writeFileSync } from "fs";
2011
+ import { randomUUID as randomUUID2 } from "crypto";
2012
+ import path4 from "path";
2013
+ function readAuthFile() {
2014
+ let raw;
1810
2015
  try {
1811
- execFileSync("which", [bin], { stdio: "ignore" });
1812
- return true;
2016
+ raw = readFileSync(authPath(), "utf8");
1813
2017
  } catch {
1814
- return false;
2018
+ throw new ChatGptAuthError(
2019
+ "No ChatGPT session found. Install the Codex CLI (npm i -g @openai/codex), then run `hd connect`."
2020
+ );
2021
+ }
2022
+ try {
2023
+ return JSON.parse(raw);
2024
+ } catch {
2025
+ throw new ChatGptAuthError(`${authPath()} is unreadable. Run \`hd connect\`.`);
1815
2026
  }
1816
2027
  }
1817
- function detectProviders() {
1818
- const found = [];
1819
- for (const [provider, bin] of Object.entries(PROVIDER_BINS)) {
1820
- if (isOnPath(bin)) found.push(provider);
2028
+ function msUntilExpiry(jwt, now = Date.now()) {
2029
+ try {
2030
+ const payload = jwt.split(".")[1];
2031
+ if (!payload) return Number.POSITIVE_INFINITY;
2032
+ const claims = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
2033
+ if (typeof claims.exp !== "number") return Number.POSITIVE_INFINITY;
2034
+ return claims.exp * 1e3 - now;
2035
+ } catch {
2036
+ return Number.POSITIVE_INFINITY;
1821
2037
  }
1822
- return found;
1823
2038
  }
1824
- async function captureCli(bin, args, timeoutMs = MODEL_DISCOVER_TIMEOUT_MS) {
2039
+ function mergeRefreshed(file, body, at = /* @__PURE__ */ new Date()) {
2040
+ return {
2041
+ ...file,
2042
+ tokens: {
2043
+ ...file.tokens,
2044
+ access_token: body.access_token,
2045
+ // Refreshing rotates this. Dropping the new value would leave both this
2046
+ // CLI and `codex login` holding a token the server has retired.
2047
+ ...body.refresh_token ? { refresh_token: body.refresh_token } : {},
2048
+ ...body.id_token ? { id_token: body.id_token } : {}
2049
+ },
2050
+ last_refresh: at.toISOString()
2051
+ };
2052
+ }
2053
+ async function refreshSession(file) {
2054
+ const refresh = file.tokens?.refresh_token;
2055
+ if (!refresh) throw new ChatGptAuthError("Your ChatGPT session has expired. Run `hd connect`.");
2056
+ let res;
1825
2057
  try {
1826
- const { stdout, stderr } = await execFileAsync(bin, args, {
1827
- encoding: "utf8",
1828
- timeout: timeoutMs,
1829
- maxBuffer: 512 * 1024
2058
+ res = await fetch(TOKEN_ENDPOINT, {
2059
+ method: "POST",
2060
+ headers: { "Content-Type": "application/json" },
2061
+ body: JSON.stringify({
2062
+ grant_type: "refresh_token",
2063
+ refresh_token: refresh,
2064
+ client_id: CLIENT_ID,
2065
+ scope: "openid profile email"
2066
+ })
1830
2067
  });
1831
- return [stdout, stderr].filter(Boolean).join("\n");
1832
- } catch (error) {
1833
- if (error && typeof error === "object") {
1834
- const err = error;
1835
- const text = [err.stdout, err.stderr].filter(Boolean).join("\n").trim();
1836
- if (text) return text;
2068
+ } catch {
2069
+ throw new ChatGptAuthError("Could not reach OpenAI to renew your ChatGPT session.");
2070
+ }
2071
+ if (!res.ok) throw new ChatGptAuthError("Your ChatGPT session could not be renewed. Run `hd connect`.");
2072
+ const body = await res.json();
2073
+ if (!body.access_token) {
2074
+ throw new ChatGptAuthError("Your ChatGPT session could not be renewed. Run `hd connect`.");
2075
+ }
2076
+ const merged = mergeRefreshed(file, body);
2077
+ const target = authPath();
2078
+ const tmp = `${target}.hd.tmp`;
2079
+ try {
2080
+ writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}
2081
+ `, { mode: 384 });
2082
+ renameSync(tmp, target);
2083
+ } catch {
2084
+ }
2085
+ return merged;
2086
+ }
2087
+ async function getAuth(force = false) {
2088
+ let file = readAuthFile();
2089
+ const accessToken = file.tokens?.access_token;
2090
+ const accountId = file.tokens?.account_id;
2091
+ if (!accessToken || !accountId) {
2092
+ throw new ChatGptAuthError("No ChatGPT subscription token found. Run `hd connect`.");
2093
+ }
2094
+ if (force || msUntilExpiry(accessToken) < RENEW_BEFORE_MS) {
2095
+ file = await refreshSession(file);
2096
+ }
2097
+ return {
2098
+ accessToken: file.tokens?.access_token ?? accessToken,
2099
+ accountId: file.tokens?.account_id ?? accountId
2100
+ };
2101
+ }
2102
+ async function streamTurn(opts) {
2103
+ const res = await fetch(ENDPOINT, {
2104
+ method: "POST",
2105
+ signal: opts.signal,
2106
+ headers: {
2107
+ "Content-Type": "application/json",
2108
+ Authorization: `Bearer ${opts.auth.accessToken}`,
2109
+ "chatgpt-account-id": opts.auth.accountId,
2110
+ "OpenAI-Beta": "responses=experimental",
2111
+ originator: "codex_cli_rs",
2112
+ session_id: opts.sessionId,
2113
+ Accept: "text/event-stream"
2114
+ },
2115
+ body: JSON.stringify({
2116
+ model: opts.model,
2117
+ instructions: opts.instructions,
2118
+ input: opts.input,
2119
+ tools: opts.tools.map((tool) => ({
2120
+ type: "function",
2121
+ name: tool.name,
2122
+ description: tool.description,
2123
+ strict: false,
2124
+ parameters: tool.parameters ?? { type: "object", properties: {} }
2125
+ })),
2126
+ tool_choice: "auto",
2127
+ parallel_tool_calls: false,
2128
+ store: false,
2129
+ stream: true
2130
+ })
2131
+ });
2132
+ if (res.status === 401 || res.status === 403) {
2133
+ throw new ChatGptAuthError("ChatGPT rejected the session. Run `hd connect`.");
2134
+ }
2135
+ if (!res.ok) {
2136
+ const body = (await res.text().catch(() => "")).slice(0, 300);
2137
+ throw new Error(`ChatGPT request failed (${res.status}): ${body || res.statusText}`);
2138
+ }
2139
+ if (!res.body) throw new Error("ChatGPT returned no body.");
2140
+ const out2 = { text: "", calls: [], usage: null };
2141
+ const reader = res.body.getReader();
2142
+ const decoder = new TextDecoder();
2143
+ let buf = "";
2144
+ for (; ; ) {
2145
+ const { done, value } = await reader.read();
2146
+ if (done) break;
2147
+ buf += decoder.decode(value, { stream: true });
2148
+ let nl;
2149
+ while ((nl = buf.indexOf("\n")) >= 0) {
2150
+ const line = buf.slice(0, nl).trim();
2151
+ buf = buf.slice(nl + 1);
2152
+ if (!line.startsWith("data:")) continue;
2153
+ const payload = line.slice(5).trim();
2154
+ if (!payload || payload === "[DONE]") continue;
2155
+ let event;
2156
+ try {
2157
+ event = JSON.parse(payload);
2158
+ } catch {
2159
+ continue;
2160
+ }
2161
+ if (event.type === "response.output_text.delta" && event.delta) {
2162
+ out2.text += event.delta;
2163
+ opts.onText?.(event.delta);
2164
+ } else if (event.type === "response.output_item.done" && event.item?.type === "function_call") {
2165
+ out2.calls.push({
2166
+ name: String(event.item.name ?? ""),
2167
+ arguments: String(event.item.arguments ?? "{}"),
2168
+ call_id: String(event.item.call_id ?? "")
2169
+ });
2170
+ } else if (event.type === "response.completed" && event.response?.usage) {
2171
+ out2.usage = event.response.usage;
2172
+ } else if (event.type === "response.failed") {
2173
+ throw new Error(event.response?.error?.message ?? "ChatGPT reported a failed response.");
2174
+ }
1837
2175
  }
1838
- return "";
1839
2176
  }
2177
+ return out2;
1840
2178
  }
1841
- var execFileAsync, PROVIDER_BINS, MODEL_DISCOVER_TIMEOUT_MS;
1842
- var init_providers = __esm({
1843
- "../runner/src/providers.ts"() {
2179
+ async function runAgent(opts) {
2180
+ let auth = await getAuth();
2181
+ const byName = new Map(opts.tools.map((tool) => [tool.name, tool]));
2182
+ const specs = opts.tools.map((tool) => ({
2183
+ name: tool.name,
2184
+ description: tool.description,
2185
+ parameters: tool.parameters
2186
+ }));
2187
+ const input = [];
2188
+ for (const message of opts.history.slice(-20)) {
2189
+ input.push({
2190
+ type: "message",
2191
+ role: message.role,
2192
+ content: [
2193
+ { type: message.role === "user" ? "input_text" : "output_text", text: message.content }
2194
+ ]
2195
+ });
2196
+ }
2197
+ input.push({ type: "message", role: "user", content: [{ type: "input_text", text: opts.prompt }] });
2198
+ let text = "";
2199
+ let usage = null;
2200
+ let renewed = false;
2201
+ let toolCalls = 0;
2202
+ let step = 0;
2203
+ const maxSteps = opts.maxSteps ?? MAX_STEPS;
2204
+ for (; step < maxSteps; step += 1) {
2205
+ let turn;
2206
+ const send = () => streamTurn({
2207
+ auth,
2208
+ sessionId: opts.sessionId,
2209
+ model: opts.model,
2210
+ instructions: opts.instructions,
2211
+ input,
2212
+ tools: specs,
2213
+ onText: (delta) => opts.onEvent?.({ kind: "text", delta }),
2214
+ signal: opts.signal
2215
+ });
2216
+ try {
2217
+ turn = await send();
2218
+ } catch (error) {
2219
+ if (error instanceof ChatGptAuthError && !renewed) {
2220
+ renewed = true;
2221
+ auth = await getAuth(true);
2222
+ turn = await send();
2223
+ } else {
2224
+ throw error;
2225
+ }
2226
+ }
2227
+ if (turn.usage) usage = turn.usage;
2228
+ if (turn.text) text = turn.text;
2229
+ if (turn.calls.length === 0) break;
2230
+ for (const call of turn.calls) {
2231
+ toolCalls += 1;
2232
+ let args = {};
2233
+ try {
2234
+ args = JSON.parse(call.arguments || "{}");
2235
+ } catch {
2236
+ }
2237
+ opts.onEvent?.({ kind: "tool", name: call.name, args });
2238
+ const tool = byName.get(call.name);
2239
+ let output;
2240
+ let ok2 = true;
2241
+ if (!tool) {
2242
+ ok2 = false;
2243
+ output = `Error: no tool named ${call.name}.`;
2244
+ } else {
2245
+ try {
2246
+ const result = await tool.run(args);
2247
+ output = typeof result === "string" ? result : JSON.stringify(result ?? null);
2248
+ if (!output) output = "(no result)";
2249
+ } catch (error) {
2250
+ ok2 = false;
2251
+ output = `Error: ${error instanceof Error ? error.message : String(error)}`;
2252
+ }
2253
+ }
2254
+ opts.onEvent?.({ kind: "tool_result", name: call.name, ok: ok2, detail: output.slice(0, 200) });
2255
+ input.push({
2256
+ type: "function_call",
2257
+ name: call.name,
2258
+ arguments: call.arguments,
2259
+ call_id: call.call_id
2260
+ });
2261
+ input.push({ type: "function_call_output", call_id: call.call_id, output });
2262
+ }
2263
+ }
2264
+ return { text: text.trim(), usage, steps: step + 1, toolCalls };
2265
+ }
2266
+ function newSessionId() {
2267
+ return randomUUID2();
2268
+ }
2269
+ var ChatGptAuthError, ENDPOINT, TOKEN_ENDPOINT, CLIENT_ID, RENEW_BEFORE_MS, authPath, MAX_STEPS;
2270
+ var init_chatgpt = __esm({
2271
+ "src/architect/chatgpt.ts"() {
1844
2272
  "use strict";
1845
- execFileAsync = promisify2(execFile2);
1846
- PROVIDER_BINS = {
1847
- claude: "claude",
1848
- codex: "codex",
1849
- gemini: "agy",
1850
- grok: "grok"
2273
+ init_connection();
2274
+ ChatGptAuthError = class extends Error {
1851
2275
  };
1852
- MODEL_DISCOVER_TIMEOUT_MS = 2e3;
2276
+ ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
2277
+ TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
2278
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
2279
+ RENEW_BEFORE_MS = 60 * 60 * 1e3;
2280
+ authPath = () => path4.join(codexHome(), "auth.json");
2281
+ MAX_STEPS = 16;
1853
2282
  }
1854
2283
  });
1855
2284
 
@@ -2061,6 +2490,56 @@ var init_App = __esm({
2061
2490
  }
2062
2491
  });
2063
2492
 
2493
+ // ../runner/src/providers.ts
2494
+ import { execFile as execFile3, execFileSync } from "child_process";
2495
+ import { promisify as promisify3 } from "util";
2496
+ function isOnPath(bin) {
2497
+ try {
2498
+ execFileSync("which", [bin], { stdio: "ignore" });
2499
+ return true;
2500
+ } catch {
2501
+ return false;
2502
+ }
2503
+ }
2504
+ function detectProviders() {
2505
+ const found = [];
2506
+ for (const [provider, bin] of Object.entries(PROVIDER_BINS)) {
2507
+ if (isOnPath(bin)) found.push(provider);
2508
+ }
2509
+ return found;
2510
+ }
2511
+ async function captureCli(bin, args, timeoutMs = MODEL_DISCOVER_TIMEOUT_MS) {
2512
+ try {
2513
+ const { stdout, stderr } = await execFileAsync(bin, args, {
2514
+ encoding: "utf8",
2515
+ timeout: timeoutMs,
2516
+ maxBuffer: 512 * 1024
2517
+ });
2518
+ return [stdout, stderr].filter(Boolean).join("\n");
2519
+ } catch (error) {
2520
+ if (error && typeof error === "object") {
2521
+ const err = error;
2522
+ const text = [err.stdout, err.stderr].filter(Boolean).join("\n").trim();
2523
+ if (text) return text;
2524
+ }
2525
+ return "";
2526
+ }
2527
+ }
2528
+ var execFileAsync, PROVIDER_BINS, MODEL_DISCOVER_TIMEOUT_MS;
2529
+ var init_providers = __esm({
2530
+ "../runner/src/providers.ts"() {
2531
+ "use strict";
2532
+ execFileAsync = promisify3(execFile3);
2533
+ PROVIDER_BINS = {
2534
+ claude: "claude",
2535
+ codex: "codex",
2536
+ gemini: "agy",
2537
+ grok: "grok"
2538
+ };
2539
+ MODEL_DISCOVER_TIMEOUT_MS = 2e3;
2540
+ }
2541
+ });
2542
+
2064
2543
  // ../runner/src/auth-check.ts
2065
2544
  function firstLine(text) {
2066
2545
  const line = text.split(/\r?\n/).map((row) => row.trim()).find(Boolean);
@@ -2268,8 +2747,8 @@ __export(init_exports, {
2268
2747
  parseInitArgs: () => parseInitArgs
2269
2748
  });
2270
2749
  import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
2271
- import { homedir as homedir5, hostname, tmpdir as tmpdir2 } from "os";
2272
- import path6 from "path";
2750
+ import { homedir as homedir4, hostname, tmpdir as tmpdir2 } from "os";
2751
+ import path7 from "path";
2273
2752
  import { fileURLToPath } from "url";
2274
2753
  import { execFile as execFile4 } from "child_process";
2275
2754
  import { promisify as promisify4 } from "util";
@@ -2334,7 +2813,7 @@ function xmlEscape(value) {
2334
2813
  }
2335
2814
  function launchdPlist(opts) {
2336
2815
  const label = opts.label ?? LAUNCHD_LABEL;
2337
- const program = path6.join(opts.repoRoot, "deploy/run-runner.sh");
2816
+ const program = path7.join(opts.repoRoot, "deploy/run-runner.sh");
2338
2817
  return `<?xml version="1.0" encoding="UTF-8"?>
2339
2818
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2340
2819
  <plist version="1.0">
@@ -2365,16 +2844,16 @@ function launchdPlist(opts) {
2365
2844
  `;
2366
2845
  }
2367
2846
  function defaultRepoRoot(fromFile = fileURLToPath(import.meta.url)) {
2368
- return path6.resolve(path6.dirname(fromFile), "../../..");
2847
+ return path7.resolve(path7.dirname(fromFile), "../../..");
2369
2848
  }
2370
2849
  async function initHost(flags, deps = {}) {
2371
2850
  const env = deps.env ?? process.env;
2372
2851
  const platform = deps.platform ?? process.platform;
2373
- const home = (deps.homedir ?? homedir5)();
2852
+ const home = (deps.homedir ?? homedir4)();
2374
2853
  const repoRoot = defaultRepoRoot();
2375
2854
  const host = flags.host || env.RUNNER_HOST || (deps.hostname ?? hostname)().replace(/\..*$/, "").toLowerCase();
2376
- const envFile = flags.envFile || path6.join(repoRoot, "apps/runner/.env");
2377
- const worktreeRoot = flags.worktreeRoot || env.WORKTREE_ROOT || path6.join(home, "higherdev-worktrees");
2855
+ const envFile = flags.envFile || path7.join(repoRoot, "apps/runner/.env");
2856
+ const worktreeRoot = flags.worktreeRoot || env.WORKTREE_ROOT || path7.join(home, "higherdev-worktrees");
2378
2857
  const steps = [
2379
2858
  "Write the runner env file",
2380
2859
  "Register the host",
@@ -2400,13 +2879,13 @@ async function initHost(flags, deps = {}) {
2400
2879
  RUNNER_STOP_TIMEOUT_MS: existing.RUNNER_STOP_TIMEOUT_MS || "90000"
2401
2880
  });
2402
2881
  if (!flags.dryRun) {
2403
- await ensureDir(path6.dirname(envFile), { recursive: true });
2882
+ await ensureDir(path7.dirname(envFile), { recursive: true });
2404
2883
  await write(envFile, formatEnvFile(merged), "utf8");
2405
2884
  }
2406
- const plistPath = flags.plistOut || (flags.installLaunchd && !flags.dryRun && platform === "darwin" ? path6.join(home, "Library/LaunchAgents", `${LAUNCHD_LABEL}.plist`) : path6.join((deps.tmpdir ?? tmpdir2)(), "higherdev-runner.plist"));
2407
- const logPath = path6.join(home, "Library/Logs/higherdev-runner.log");
2885
+ const plistPath = flags.plistOut || (flags.installLaunchd && !flags.dryRun && platform === "darwin" ? path7.join(home, "Library/LaunchAgents", `${LAUNCHD_LABEL}.plist`) : path7.join((deps.tmpdir ?? tmpdir2)(), "higherdev-runner.plist"));
2886
+ const logPath = path7.join(home, "Library/Logs/higherdev-runner.log");
2408
2887
  const plist = launchdPlist({ repoRoot, envFile, logPath });
2409
- await ensureDir(path6.dirname(plistPath), { recursive: true });
2888
+ await ensureDir(path7.dirname(plistPath), { recursive: true });
2410
2889
  await write(plistPath, plist, "utf8");
2411
2890
  let launchdInstalled = false;
2412
2891
  if (flags.installLaunchd && !flags.dryRun && platform === "darwin") {
@@ -2671,154 +3150,56 @@ async function currentEmail(db) {
2671
3150
  function normalizeEmail(email) {
2672
3151
  return email.trim().toLowerCase();
2673
3152
  }
2674
- async function serverAllows(db) {
2675
- const { data, error } = await db.rpc("is_allowlisted");
2676
- if (error) return false;
2677
- return data === true;
2678
- }
2679
- async function audit(db, email, workspaceId, action, payload) {
2680
- const { error } = await db.from("audit_events").insert(auditEventRow({ workspace_id: workspaceId, actor: email, action, payload }));
2681
- if (!error) return;
2682
- process.stderr.write(
2683
- `warning: the change was made but was not written to the audit log (${error.message}).
2684
- Apply the pending migrations in supabase/migrations.
2685
- `
2686
- );
2687
- }
2688
- async function requireCtx() {
2689
- const db = await createDb();
2690
- const email = await currentEmail(db);
2691
- if (!email) fail("Not signed in. Run `hd login`.");
2692
- if (!await serverAllows(db)) {
2693
- fail(`${email} is not allowed on this HigherDEV. Ask its owner to add you.`);
2694
- }
2695
- return { db, email, audit: (workspaceId, action, payload) => audit(db, email, workspaceId, action, payload) };
2696
- }
2697
- async function requireWorkspace(slugOverride) {
2698
- const ctx = await requireCtx();
2699
- const config = await loadConfig();
2700
- const slug = slugOverride ?? process.env.HIGHERDEV_WORKSPACE ?? config.workspace;
2701
- if (slug) {
2702
- const workspace = await workspaceBySlug(ctx.db, slug);
2703
- if (!workspace) fail(`No workspace named ${slug}. Run \`hd workspace ls\`.`);
2704
- return { ...ctx, workspace };
2705
- }
2706
- const { data, error } = await ctx.db.from("workspaces").select("*").order("created_at");
2707
- if (error) fail(error.message);
2708
- const rows = data ?? [];
2709
- if (rows.length === 1) {
2710
- await saveConfig({ workspace: rows[0].slug });
2711
- return { ...ctx, workspace: rows[0] };
2712
- }
2713
- if (rows.length === 0) fail("No workspaces yet. Run `hd workspace new <name> <owner/repo>`.");
2714
- fail(
2715
- `Pick a workspace: ${rows.map((row) => row.slug).join(", ")}.
2716
- Run \`hd use <slug>\` or pass --workspace.`
2717
- );
2718
- }
2719
-
2720
- // src/auth.ts
2721
- init_theme();
2722
- init_json();
2723
-
2724
- // src/architect/connection.ts
2725
- import { execFile, spawn } from "child_process";
2726
- import { access, readFile as readFile2 } from "fs/promises";
2727
- import { constants } from "fs";
2728
- import { homedir as homedir2 } from "os";
2729
- import path2 from "path";
2730
- import { promisify } from "util";
2731
- var run = promisify(execFile);
2732
- function codexHome(env = process.env) {
2733
- return env.CODEX_HOME ?? path2.join(homedir2(), ".codex");
2734
- }
2735
- function classifyAuthFile(auth) {
2736
- if (!auth) return null;
2737
- const apiKey = typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY.trim() : "";
2738
- if (apiKey) {
2739
- return {
2740
- state: "api_key",
2741
- detail: "Codex is signed in with an API key, which bills per token."
2742
- };
2743
- }
2744
- const refresh = auth.tokens?.refresh_token;
2745
- if (auth.auth_mode === "chatgpt" && typeof refresh === "string" && refresh.length > 0) {
2746
- const accountId = typeof auth.tokens?.account_id === "string" ? auth.tokens.account_id : null;
2747
- return { state: "connected", accountId, detail: "Codex is connected to a ChatGPT subscription." };
2748
- }
2749
- return { state: "signed_out", detail: "Codex is installed but not signed in." };
2750
- }
2751
- async function onPath(bin, env = process.env) {
2752
- const dirs = (env.PATH ?? "").split(path2.delimiter).filter(Boolean);
2753
- for (const dir of dirs) {
2754
- try {
2755
- await access(path2.join(dir, bin), constants.X_OK);
2756
- return true;
2757
- } catch {
2758
- }
2759
- }
2760
- return false;
2761
- }
2762
- async function codexConnection(env = process.env) {
2763
- if (!await onPath("codex")) {
2764
- return {
2765
- state: "not_installed",
2766
- detail: "The Codex CLI is not on PATH. Install it, then run `hd connect`."
2767
- };
2768
- }
2769
- let auth = null;
2770
- try {
2771
- auth = JSON.parse(await readFile2(path2.join(codexHome(env), "auth.json"), "utf8"));
2772
- } catch {
2773
- auth = null;
2774
- }
2775
- const fromFile = classifyAuthFile(auth);
2776
- if (fromFile) return fromFile;
2777
- try {
2778
- const { stdout } = await run("codex", ["login", "status"], { timeout: 2e4 });
2779
- const text = stdout.trim();
2780
- if (/chatgpt/i.test(text)) {
2781
- return { state: "connected", accountId: null, detail: text };
2782
- }
2783
- if (/api key/i.test(text)) {
2784
- return { state: "api_key", detail: text };
2785
- }
2786
- return { state: "signed_out", detail: text || "Codex is not signed in." };
2787
- } catch {
2788
- return { state: "signed_out", detail: "Codex is installed but not signed in." };
2789
- }
3153
+ async function serverAllows(db) {
3154
+ const { data, error } = await db.rpc("is_allowlisted");
3155
+ if (error) return false;
3156
+ return data === true;
2790
3157
  }
2791
- function connectionSummary(connection) {
2792
- switch (connection.state) {
2793
- case "connected":
2794
- return connection.accountId ? `${connection.detail} Account ${connection.accountId}.` : connection.detail;
2795
- default:
2796
- return connection.detail;
2797
- }
3158
+ async function audit(db, email, workspaceId, action, payload) {
3159
+ const { error } = await db.from("audit_events").insert(auditEventRow({ workspace_id: workspaceId, actor: email, action, payload }));
3160
+ if (!error) return;
3161
+ process.stderr.write(
3162
+ `warning: the change was made but was not written to the audit log (${error.message}).
3163
+ Apply the pending migrations in supabase/migrations.
3164
+ `
3165
+ );
2798
3166
  }
2799
- function howToFix(connection) {
2800
- switch (connection.state) {
2801
- case "connected":
2802
- return null;
2803
- case "not_installed":
2804
- return "Install the Codex CLI, then run `hd connect`.";
2805
- case "api_key":
2806
- return "Run `codex logout` then `hd connect` to sign in with the subscription instead.";
2807
- default:
2808
- return "Run `hd connect` to sign in through ChatGPT.";
3167
+ async function requireCtx() {
3168
+ const db = await createDb();
3169
+ const email = await currentEmail(db);
3170
+ if (!email) fail("Not signed in. Run `hd login`.");
3171
+ if (!await serverAllows(db)) {
3172
+ fail(`${email} is not allowed on this HigherDEV. Ask its owner to add you.`);
2809
3173
  }
3174
+ return { db, email, audit: (workspaceId, action, payload) => audit(db, email, workspaceId, action, payload) };
2810
3175
  }
2811
- async function connectCodex() {
2812
- const code = await new Promise((resolve, reject) => {
2813
- const child = spawn("codex", ["login"], { stdio: "inherit" });
2814
- child.on("error", reject);
2815
- child.on("exit", (status) => resolve(status ?? 1));
2816
- });
2817
- if (code !== 0) throw new Error(`codex login exited ${code}`);
2818
- return codexConnection();
3176
+ async function requireWorkspace(slugOverride) {
3177
+ const ctx = await requireCtx();
3178
+ const config = await loadConfig();
3179
+ const slug = slugOverride ?? process.env.HIGHERDEV_WORKSPACE ?? config.workspace;
3180
+ if (slug) {
3181
+ const workspace = await workspaceBySlug(ctx.db, slug);
3182
+ if (!workspace) fail(`No workspace named ${slug}. Run \`hd workspace ls\`.`);
3183
+ return { ...ctx, workspace };
3184
+ }
3185
+ const { data, error } = await ctx.db.from("workspaces").select("*").order("created_at");
3186
+ if (error) fail(error.message);
3187
+ const rows = data ?? [];
3188
+ if (rows.length === 1) {
3189
+ await saveConfig({ workspace: rows[0].slug });
3190
+ return { ...ctx, workspace: rows[0] };
3191
+ }
3192
+ if (rows.length === 0) fail("No workspaces yet. Run `hd workspace new <name> <owner/repo>`.");
3193
+ fail(
3194
+ `Pick a workspace: ${rows.map((row) => row.slug).join(", ")}.
3195
+ Run \`hd use <slug>\` or pass --workspace.`
3196
+ );
2819
3197
  }
2820
3198
 
2821
3199
  // src/auth.ts
3200
+ init_theme();
3201
+ init_json();
3202
+ init_connection();
2822
3203
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2823
3204
  var CODE_RE = /^\d{6}$/;
2824
3205
  async function prompt(question) {
@@ -3736,10 +4117,8 @@ async function createTicket(ctx, input) {
3736
4117
  assigned: Boolean(assigned.agent),
3737
4118
  blockerStatuses: blockers.statuses
3738
4119
  });
3739
- const { data, error } = await ctx.db.from("tickets").insert({
3740
- workspace_id: ctx.workspace.id,
4120
+ const { data, error } = await createTicketRecord(ctx.db, ctx.workspace.id, {
3741
4121
  epic_id: input.epic ?? null,
3742
- key: "",
3743
4122
  title,
3744
4123
  body_md: input.body ?? "",
3745
4124
  acceptance_md: acceptance,
@@ -3752,7 +4131,7 @@ async function createTicket(ctx, input) {
3752
4131
  provider_pinned: Boolean(assigned.agent),
3753
4132
  blocked_by: blockers.ids,
3754
4133
  created_by: "human"
3755
- }).select("*").single();
4134
+ });
3756
4135
  if (error) fail(error.message);
3757
4136
  await ctx.audit(ctx.workspace.id, "create", { ticket_key: data.key, subject: "a ticket" });
3758
4137
  return data;
@@ -3840,7 +4219,12 @@ async function handBack(ctx, ticket) {
3840
4219
  return data;
3841
4220
  }
3842
4221
  async function killRun(ctx, runId, ticketKey) {
3843
- const { data, error } = await ctx.db.from("runs").update({ status: "killed", ended_at: (/* @__PURE__ */ new Date()).toISOString(), summary: `Killed by ${ctx.email}.` }).eq("id", runId).eq("workspace_id", ctx.workspace.id).select("*").maybeSingle();
4222
+ const { data, error } = await ctx.db.rpc("request_run_cancellation", {
4223
+ p_run_id: runId,
4224
+ p_workspace_id: ctx.workspace.id,
4225
+ p_requested_by: ctx.email,
4226
+ p_reason: `Cancellation requested by ${ctx.email}.`
4227
+ });
3844
4228
  if (error) fail(error.message);
3845
4229
  if (!data) fail(`No run ${runId} in ${ctx.workspace.slug}.`);
3846
4230
  await ctx.audit(ctx.workspace.id, "kill", { ticket_key: ticketKey, run_id: runId });
@@ -3972,14 +4356,14 @@ function registerWriteCommands(program) {
3972
4356
  const live = runs2.find((run6) => run6.status === "running" || run6.status === "queued");
3973
4357
  if (!live) fail(`${ticket.key} has no live run.`);
3974
4358
  const killed2 = await killRun(ctx, live.id, ticket.key);
3975
- ok(`Killed ${c.dim(killed2.id.slice(0, 8))} on ${c.bold(ticket.key)}.`, { run: killed2 });
4359
+ ok(`Cancellation requested for ${c.dim(killed2.id.slice(0, 8))} on ${c.bold(ticket.key)}.`, { run: killed2 });
3976
4360
  return;
3977
4361
  }
3978
4362
  const runs = await loadRuns(ctx, { limit: 200 });
3979
4363
  const run5 = runs.find((row) => row.id.startsWith(target));
3980
4364
  if (!run5) fail(`No run matching ${target}.`);
3981
4365
  const killed = await killRun(ctx, run5.id);
3982
- ok(`Killed ${c.dim(killed.id.slice(0, 8))}.`, { run: killed });
4366
+ ok(`Cancellation requested for ${c.dim(killed.id.slice(0, 8))}.`, { run: killed });
3983
4367
  });
3984
4368
  const epic = group(program, "epic", "epics and their dependency waves");
3985
4369
  epic.command("new").argument("<title>", "epic title").argument("[spec]", "path to a markdown spec").description("create an epic; the orchestrator decomposes it").action(async function(title, spec) {
@@ -4290,7 +4674,7 @@ function registerAgentCommands(program) {
4290
4674
  row.role,
4291
4675
  row.provider,
4292
4676
  c.dim(row.model),
4293
- row.effort,
4677
+ asEffort(row.effort),
4294
4678
  row.enabled ? c.green("yes") : c.dim("no"),
4295
4679
  truncate(row.routing_notes ?? "", 44)
4296
4680
  ])
@@ -4304,7 +4688,7 @@ function registerAgentCommands(program) {
4304
4688
  found,
4305
4689
  () => [
4306
4690
  `${c.bold(found.display_name)} ${c.dim(found.role)}`,
4307
- `${c.dim("model")} ${found.provider}/${found.model} ${c.dim(`effort ${found.effort}`)}`,
4691
+ `${c.dim("model")} ${found.provider}/${found.model} ${c.dim(`effort ${asEffort(found.effort)}`)}`,
4308
4692
  `${c.dim("enabled")} ${found.enabled ? "yes" : "no"}`,
4309
4693
  `${c.dim("caps")} ${found.runs_per_hour ?? "unlimited"} runs/hour, ${found.daily_spend_usd ?? "unlimited"} usd/day`,
4310
4694
  found.routing_notes ? `
@@ -4359,7 +4743,7 @@ ${found.prompt_addendum}` : ""
4359
4743
  await ctx.audit(ctx.workspace.id, "configure", { subject: `agent ${data.display_name}` });
4360
4744
  ok(`${c.bold(data.display_name)} updated.`, { agent: data });
4361
4745
  });
4362
- agent.command("new").argument("<name>", "display name").description("add a builder").requiredOption("--provider <provider>", "claude, codex, gemini, or grok").requiredOption("--model <model>", "model id").option("--effort <level>", "low, medium, or high", "medium").option("--notes <text>", "routing notes").action(async function(name) {
4746
+ agent.command("new").argument("<name>", "display name").description("add a builder").requiredOption("--provider <provider>", "claude, codex, gemini, or grok").requiredOption("--model <model>", "model id").option("--effort <level>", "low, medium, or high", DEFAULT_EFFORT).option("--notes <text>", "routing notes").action(async function(name) {
4363
4747
  const opts = this.opts();
4364
4748
  const ctx = await requireWorkspace(slugOf5(this));
4365
4749
  if (!providers.includes(opts.provider)) {
@@ -4444,8 +4828,13 @@ function registerWorkspaceCommands(program) {
4444
4828
  default_host: opts.host
4445
4829
  }).select("*").single();
4446
4830
  if (error) fail(error.message);
4831
+ const { error: agentsError } = await ctx.db.from("agents").insert(DEFAULT_AGENTS.map((agent) => ({ ...agent, workspace_id: data.id })));
4832
+ if (agentsError) fail(`Workspace created, but seeding its agents failed: ${agentsError.message}`);
4447
4833
  await ctx.audit(data.id, "create", { subject: `workspace ${data.slug}` });
4448
- ok(`${c.bold(data.slug)} created. Run \`hd use ${data.slug}\`.`, { workspace: data });
4834
+ ok(
4835
+ `${c.bold(data.slug)} created with ${DEFAULT_AGENTS.length} agents. Run \`hd use ${data.slug}\`.`,
4836
+ { workspace: data }
4837
+ );
4449
4838
  });
4450
4839
  workspace.command("set").description("change workspace settings").option("--branch <name>", "default branch").option("--host <id>", "default host").option("--auto-merge", "merge approved PRs automatically").option("--no-auto-merge", "wait for a human to merge").option("--cap <provider=n...>", "set a provider cap, e.g. --cap claude=2").action(async function() {
4451
4840
  const opts = this.opts();
@@ -4536,22 +4925,22 @@ function registerAttachCommands(program) {
4536
4925
  if (!contentType) fail(`${name} is not a jpg, png, gif, or webp.`);
4537
4926
  const ext = extensionForContentType(contentType);
4538
4927
  if (!ext) fail(`${name} is not a jpg, png, gif, or webp.`);
4539
- const path7 = attachmentObjectPath(ctx.workspace.id, ticket.id, randomUUID(), ext);
4540
- const { error } = await ctx.db.storage.from(TICKET_IMAGES_BUCKET).upload(path7, bytes, { contentType, upsert: false });
4928
+ const path8 = attachmentObjectPath(ctx.workspace.id, ticket.id, randomUUID(), ext);
4929
+ const { error } = await ctx.db.storage.from(TICKET_IMAGES_BUCKET).upload(path8, bytes, { contentType, upsert: false });
4541
4930
  if (error) fail(`${name}: ${error.message}`);
4542
4931
  const { error: rowError } = await ctx.db.from("attachments").insert({
4543
4932
  workspace_id: ctx.workspace.id,
4544
4933
  ticket_id: ticket.id,
4545
4934
  message_id: null,
4546
- path: path7,
4935
+ path: path8,
4547
4936
  content_type: contentType,
4548
4937
  created_by: "human"
4549
4938
  });
4550
4939
  if (rowError) {
4551
- await ctx.db.storage.from(TICKET_IMAGES_BUCKET).remove([path7]);
4940
+ await ctx.db.storage.from(TICKET_IMAGES_BUCKET).remove([path8]);
4552
4941
  fail(rowError.message);
4553
4942
  }
4554
- uploaded.push(path7);
4943
+ uploaded.push(path8);
4555
4944
  }
4556
4945
  await ctx.audit(ctx.workspace.id, "create", { ticket_key: ticket.key, subject: "attachments" });
4557
4946
  ok(`Attached ${uploaded.length} file${uploaded.length === 1 ? "" : "s"} to ${c.bold(ticket.key)}.`, {
@@ -4587,7 +4976,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4587
4976
  import { z as z3 } from "zod";
4588
4977
 
4589
4978
  // src/version.ts
4590
- var VERSION = "0.1.1";
4979
+ var VERSION = "0.1.3";
4591
4980
 
4592
4981
  // src/architect/tools.ts
4593
4982
  init_src();
@@ -5019,6 +5408,13 @@ var TOOLS = [
5019
5408
  function toolsFor(access2) {
5020
5409
  return access2 === "read" ? TOOLS.filter((tool) => tool.access === "read") : TOOLS;
5021
5410
  }
5411
+ function toolJsonSchema(tool) {
5412
+ const schema = z2.toJSONSchema(tool.schema);
5413
+ delete schema.$schema;
5414
+ if (!schema.type) schema.type = "object";
5415
+ if (!schema.properties) schema.properties = {};
5416
+ return schema;
5417
+ }
5022
5418
  async function toolContext(slug) {
5023
5419
  return requireWorkspace(slug);
5024
5420
  }
@@ -5090,385 +5486,262 @@ async function serveMcp(opts) {
5090
5486
  }
5091
5487
 
5092
5488
  // src/architect/plan.ts
5093
- init_src();
5094
- import { execFile as execFile3 } from "child_process";
5095
- import { mkdir as mkdir2 } from "fs/promises";
5096
- import { homedir as homedir4 } from "os";
5097
- import path5 from "path";
5098
- import { promisify as promisify3 } from "util";
5099
5489
  init_json();
5100
5490
  init_theme();
5101
-
5102
- // src/architect/codex.ts
5103
- import { spawn as spawn4 } from "child_process";
5104
- import { createInterface as createInterface2 } from "readline";
5105
-
5106
- // ../runner/src/adapters/codex.ts
5107
- init_providers();
5491
+ init_chatgpt();
5492
+ import { execFile as execFile2 } from "child_process";
5493
+ import { mkdir as mkdir2 } from "fs/promises";
5108
5494
  import { homedir as homedir3 } from "os";
5109
- import path4 from "path";
5110
- import { readFile as readFile6 } from "fs/promises";
5495
+ import path6 from "path";
5496
+ import { promisify as promisify2 } from "util";
5111
5497
 
5112
- // ../runner/src/adapters/final.ts
5113
- var OUTCOMES = /* @__PURE__ */ new Set(["pr_opened", "pr_updated", "blocked", "failed"]);
5114
- function extractFinal(text) {
5115
- const trimmed = text.trim();
5116
- const candidates = [trimmed];
5117
- const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
5118
- if (fence?.[1]) candidates.unshift(fence[1].trim());
5119
- const firstBrace = trimmed.indexOf("{");
5120
- const lastBrace = trimmed.lastIndexOf("}");
5121
- if (firstBrace >= 0 && lastBrace > firstBrace) {
5122
- candidates.unshift(trimmed.slice(firstBrace, lastBrace + 1));
5123
- }
5124
- for (const candidate of candidates) {
5125
- try {
5126
- const parsed = JSON.parse(candidate);
5127
- if (parsed && typeof parsed.summary === "string" && parsed.outcome && OUTCOMES.has(parsed.outcome)) {
5128
- return parsed;
5129
- }
5130
- } catch {
5131
- }
5498
+ // src/architect/brain.ts
5499
+ import { createInterface as createInterface2 } from "readline/promises";
5500
+ init_json();
5501
+ init_theme();
5502
+ init_connection();
5503
+ var BRAINS = [
5504
+ {
5505
+ id: "chatgpt",
5506
+ label: "ChatGPT subscription",
5507
+ billing: "included in your ChatGPT plan, nothing per token",
5508
+ // gpt-5.6, without a suffix, is refused by this endpoint for ChatGPT accounts.
5509
+ defaultModel: "gpt-5.6-sol",
5510
+ models: ["gpt-5.6-sol", "gpt-5.5"]
5132
5511
  }
5133
- return null;
5512
+ ];
5513
+ var DEFAULT_BRAIN = {
5514
+ id: "chatgpt",
5515
+ model: "gpt-5.6-sol",
5516
+ effort: "high"
5517
+ };
5518
+ function brainById(id) {
5519
+ return BRAINS.find((brain) => brain.id === id) ?? null;
5134
5520
  }
5135
-
5136
- // ../runner/src/adapters/spawn.ts
5137
- import { spawn as spawn3 } from "child_process";
5138
- import { randomUUID as randomUUID2 } from "crypto";
5139
-
5140
- // ../runner/src/adapters/cost.ts
5141
- function costFromPayload(payload) {
5142
- if (!payload) return null;
5143
- const values = [payload.total_cost_usd, payload.cost_usd, payload.totalCostUsd, payload.costUsd];
5144
- pushUsageCosts(asRecord2(payload.usage), values);
5145
- pushUsageCosts(asRecord2(payload.info), values);
5146
- const result = asRecord2(payload.result);
5147
- if (result) {
5148
- values.push(result.total_cost_usd, result.cost_usd);
5149
- pushUsageCosts(asRecord2(result.usage), values);
5150
- }
5151
- for (const value of values) {
5152
- const n = toNonNegativeNumber(value);
5153
- if (n != null) return n;
5154
- }
5155
- return null;
5521
+ function describeBrain(config) {
5522
+ const brain = brainById(config.id);
5523
+ return `${brain?.label ?? config.id} (${config.model}, effort ${config.effort})`;
5156
5524
  }
5157
- function turnsFromPayload(payload) {
5158
- if (!payload) return null;
5159
- const values = [payload.num_turns, payload.numTurns, payload.turns];
5160
- const usage = asRecord2(payload.usage);
5161
- if (usage) values.push(usage.num_turns, usage.numTurns, usage.turns);
5162
- const result = asRecord2(payload.result);
5163
- if (result) values.push(result.num_turns, result.numTurns, result.turns);
5164
- for (const value of values) {
5165
- const n = toNonNegativeInt(value);
5166
- if (n != null) return n;
5525
+ async function confirm(question) {
5526
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
5527
+ try {
5528
+ const answer = (await rl.question(question)).trim().toLowerCase();
5529
+ return answer === "" || answer === "y" || answer === "yes";
5530
+ } finally {
5531
+ rl.close();
5167
5532
  }
5168
- return null;
5169
5533
  }
5170
- function pushUsageCosts(usage, values) {
5171
- if (!usage) return;
5172
- values.push(usage.total_cost_usd, usage.cost_usd, usage.totalCostUsd, usage.costUsd);
5173
- }
5174
- function asRecord2(value) {
5175
- return value && typeof value === "object" && !Array.isArray(value) ? value : null;
5176
- }
5177
- function toNonNegativeNumber(value) {
5178
- if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : null;
5179
- if (typeof value === "string" && value.trim()) {
5180
- const n = Number(value);
5181
- return Number.isFinite(n) && n >= 0 ? n : null;
5534
+ async function requireBrain(opts = {}) {
5535
+ const config = await loadConfig();
5536
+ const chosen = config.brain ?? DEFAULT_BRAIN;
5537
+ const interactive = opts.interactive ?? (process.stdin.isTTY && process.stdout.isTTY);
5538
+ const connection = await codexConnection();
5539
+ if (connection.state === "connected") {
5540
+ if (!config.brain) await saveConfig({ brain: chosen });
5541
+ return chosen;
5182
5542
  }
5183
- return null;
5543
+ if (!interactive) {
5544
+ fail(`${connectionSummary(connection)}
5545
+ ${howToFix(connection) ?? ""}`.trim());
5546
+ }
5547
+ if (connection.state === "not_installed") {
5548
+ fail(`${connectionSummary(connection)}
5549
+ ${howToFix(connection) ?? ""}`.trim());
5550
+ }
5551
+ const brain = brainById(chosen.id);
5552
+ out("");
5553
+ out(`${c.bold("HigherDEV needs your ChatGPT subscription to answer.")}`);
5554
+ out(c.dim(`${brain?.billing ?? ""}. The sign-in happens in your browser; hd never sees the token.`));
5555
+ if (!await confirm(c.dim("Press enter to sign in, or n to cancel: "))) {
5556
+ fail("Not connected. Run `hd brain` when you want to.");
5557
+ }
5558
+ const connected = await connectCodex();
5559
+ if (connected.state !== "connected") {
5560
+ fail(`${connectionSummary(connected)}
5561
+ ${howToFix(connected) ?? ""}`.trim());
5562
+ }
5563
+ out(c.green(connectionSummary(connected)));
5564
+ await saveConfig({ brain: chosen });
5565
+ return chosen;
5184
5566
  }
5185
- function toNonNegativeInt(value) {
5186
- const n = toNonNegativeNumber(value);
5187
- return n != null && Number.isInteger(n) ? n : null;
5567
+ async function setBrain(patch) {
5568
+ const config = await loadConfig();
5569
+ const next = { ...config.brain ?? DEFAULT_BRAIN, ...patch };
5570
+ const brain = brainById(next.id);
5571
+ if (!brain) fail(`Unknown brain ${next.id}. One of: ${BRAINS.map((row) => row.id).join(", ")}.`);
5572
+ if (patch.model && !brain.models.includes(patch.model)) {
5573
+ out(c.yellow(`${patch.model} is not one of the models known to work here (${brain.models.join(", ")}).`));
5574
+ }
5575
+ await saveConfig({ brain: next });
5576
+ return next;
5188
5577
  }
5189
5578
 
5190
- // ../runner/src/adapters/spawn.ts
5191
- function spawnCli(opts) {
5192
- let sessionId = opts.sessionId ?? randomUUID2();
5193
- const child = spawn3(opts.bin, opts.args, {
5194
- cwd: opts.cwd,
5195
- env: process.env,
5196
- stdio: ["ignore", "pipe", "pipe"]
5197
- });
5198
- const queue = [];
5199
- let notify = null;
5200
- let closed = false;
5201
- let resultText = "";
5202
- let costUsd = null;
5203
- let turns = null;
5204
- const wait = collectLines(child, (line) => {
5205
- const event = opts.parse(line);
5206
- const sid = event?.payload?.session_id;
5207
- if (typeof sid === "string" && sid) sessionId = sid;
5208
- const thread = event?.payload?.thread_id;
5209
- if (typeof thread === "string" && thread) sessionId = thread;
5210
- const structured = event?.payload?.structured_output;
5211
- if (structured && typeof structured === "object") {
5212
- resultText = JSON.stringify(structured);
5213
- } else if (typeof event?.payload?.result === "string") {
5214
- resultText = event.payload.result;
5215
- }
5216
- const cost = costFromPayload(event?.payload);
5217
- if (cost != null) costUsd = cost;
5218
- const turnCount = turnsFromPayload(event?.payload);
5219
- if (turnCount != null) turns = turnCount;
5220
- queue.push(line);
5221
- notify?.();
5222
- }).then(({ stdout, stderr, exitCode, signal }) => {
5223
- closed = true;
5224
- notify?.();
5225
- return {
5226
- exitCode,
5227
- signal,
5228
- final: extractFinal(resultText || stdout),
5229
- rawText: resultText || stdout,
5230
- stderr,
5231
- costUsd,
5232
- turns
5233
- };
5234
- });
5235
- async function* stream() {
5236
- while (!closed || queue.length > 0) {
5237
- if (queue.length === 0) {
5238
- await new Promise((resolve) => {
5239
- notify = resolve;
5240
- });
5241
- notify = null;
5242
- }
5243
- while (queue.length > 0) {
5244
- yield queue.shift();
5245
- }
5579
+ // src/architect/repo.ts
5580
+ import { readFile as readFile6, readdir, stat } from "fs/promises";
5581
+ import path5 from "path";
5582
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
5583
+ ".git",
5584
+ "node_modules",
5585
+ ".next",
5586
+ "dist",
5587
+ "build",
5588
+ "coverage",
5589
+ ".turbo",
5590
+ ".vercel",
5591
+ ".pnpm-store"
5592
+ ]);
5593
+ var MAX_FILE_BYTES = 2e5;
5594
+ var MAX_LIST = 400;
5595
+ var MAX_MATCHES = 60;
5596
+ function resolveInside(root, candidate) {
5597
+ const full = path5.resolve(root, candidate ?? ".");
5598
+ const rel = path5.relative(root, full);
5599
+ if (rel.startsWith("..") || path5.isAbsolute(rel)) {
5600
+ throw new Error(`${candidate} is outside the checkout.`);
5601
+ }
5602
+ return full;
5603
+ }
5604
+ function isSkipped(name) {
5605
+ return SKIP_DIRS.has(name) || name.startsWith(".DS_Store");
5606
+ }
5607
+ async function walk(root, dir, out2, depth) {
5608
+ if (out2.length >= MAX_LIST || depth > 12) return;
5609
+ let entries;
5610
+ try {
5611
+ entries = await readdir(dir, { withFileTypes: true });
5612
+ } catch {
5613
+ return;
5614
+ }
5615
+ for (const entry of entries) {
5616
+ if (out2.length >= MAX_LIST) return;
5617
+ if (isSkipped(entry.name)) continue;
5618
+ const full = path5.join(dir, entry.name);
5619
+ if (entry.isDirectory()) {
5620
+ await walk(root, full, out2, depth + 1);
5621
+ } else if (entry.isFile()) {
5622
+ out2.push(path5.relative(root, full));
5246
5623
  }
5247
5624
  }
5248
- return {
5249
- get sessionId() {
5250
- return sessionId;
5251
- },
5252
- stream: stream(),
5253
- kill() {
5254
- child.kill("SIGINT");
5255
- setTimeout(() => {
5256
- if (!child.killed) child.kill("SIGTERM");
5257
- }, 1e4).unref();
5258
- },
5259
- wait: () => wait
5260
- };
5261
5625
  }
5262
- function collectLines(child, onLine) {
5263
- return new Promise((resolve, reject) => {
5264
- let buffer = "";
5265
- let stdout = "";
5266
- let stderr = "";
5267
- child.stdout?.setEncoding("utf8");
5268
- child.stderr?.setEncoding("utf8");
5269
- child.stdout?.on("data", (chunk) => {
5270
- stdout += chunk;
5271
- buffer += chunk;
5272
- const lines = buffer.split("\n");
5273
- buffer = lines.pop() ?? "";
5274
- for (const line of lines) onLine(line);
5275
- });
5276
- child.stderr?.on("data", (chunk) => {
5277
- stderr += chunk;
5278
- });
5279
- child.on("error", reject);
5280
- child.on("close", (exitCode, signal) => {
5281
- if (buffer) onLine(buffer);
5282
- resolve({ stdout, stderr, exitCode, signal });
5283
- });
5284
- });
5626
+ function matchesGlob(rel, glob) {
5627
+ if (!glob) return true;
5628
+ const pattern = glob.split("").map((ch) => {
5629
+ if (ch === "*") return "\0";
5630
+ return /[.+^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
5631
+ }).join("").replace(//g, ".*").replace(//g, "[^/]*");
5632
+ return new RegExp(`^${pattern}$`).test(rel);
5285
5633
  }
5286
-
5287
- // ../runner/src/adapters/codex.ts
5288
- function effortConfig(effort) {
5289
- const level = effort ?? "high";
5290
- return ["-c", `model_reasoning_effort="${level}"`];
5291
- }
5292
- var codexAdapter = {
5293
- name: "codex",
5294
- start({ prompt: prompt2, cwd, model, effort }) {
5295
- return spawnCli({
5296
- bin: "codex",
5297
- args: [
5298
- "exec",
5299
- "--json",
5300
- "--model",
5301
- model,
5302
- ...effortConfig(effort),
5303
- "--dangerously-bypass-approvals-and-sandbox",
5304
- "-C",
5305
- cwd,
5306
- prompt2
5307
- ],
5308
- cwd,
5309
- parse: codexAdapter.parse
5310
- });
5311
- },
5312
- resume({ sessionId, prompt: prompt2, cwd, effort }) {
5313
- return spawnCli({
5314
- bin: "codex",
5315
- args: ["exec", "resume", sessionId, "--json", ...effortConfig(effort), prompt2],
5316
- cwd,
5317
- parse: codexAdapter.parse,
5318
- sessionId
5319
- });
5320
- },
5321
- parse(line) {
5322
- const trimmed = line.trim();
5323
- if (!trimmed) return null;
5324
- if (!trimmed.startsWith("{")) return { type: "text", payload: { text: trimmed } };
5325
- try {
5326
- const event = JSON.parse(trimmed);
5327
- const type = String(event.type ?? event.kind ?? "");
5328
- if (type === "thread.started" || type === "session" || type === "thread_started") {
5329
- const sessionId = typeof event.thread_id === "string" && event.thread_id || typeof event.session_id === "string" && event.session_id || void 0;
5330
- return { type: "status", payload: { phase: "init", session_id: sessionId, thread_id: sessionId } };
5331
- }
5332
- if (type.includes("tool") && (type.includes("call") || type === "tool_use")) {
5333
- return { type: "tool_use", payload: event };
5334
- }
5335
- if (type.includes("tool") && type.includes("result")) {
5336
- return { type: "tool_result", payload: event };
5634
+ function buildRepoTools(root) {
5635
+ if (!root) return [];
5636
+ return [
5637
+ {
5638
+ name: "list_files",
5639
+ description: "List files in the workspace checkout, so you can see the shape of the codebase before reading anything. Optionally filter with a glob such as apps/web/**/*.tsx.",
5640
+ parameters: {
5641
+ type: "object",
5642
+ properties: {
5643
+ dir: { type: "string", description: "Directory relative to the repo root. Defaults to the root." },
5644
+ glob: { type: "string", description: "Optional glob filter, e.g. **/*.ts" }
5645
+ }
5646
+ },
5647
+ async run(args) {
5648
+ const dir = resolveInside(root, String(args.dir ?? "."));
5649
+ const found = [];
5650
+ await walk(root, dir, found, 0);
5651
+ const glob = args.glob ? String(args.glob) : void 0;
5652
+ const filtered = found.filter((rel) => matchesGlob(rel, glob)).sort();
5653
+ return {
5654
+ root: path5.basename(root),
5655
+ count: filtered.length,
5656
+ truncated: found.length >= MAX_LIST,
5657
+ files: filtered
5658
+ };
5337
5659
  }
5338
- if (type === "item.completed" || type === "agent_message" || type === "message") {
5339
- return { type: "text", payload: event };
5660
+ },
5661
+ {
5662
+ name: "read_file",
5663
+ description: "Read a file from the workspace checkout. Large files come back truncated.",
5664
+ parameters: {
5665
+ type: "object",
5666
+ properties: {
5667
+ path: { type: "string", description: "File path relative to the repo root." }
5668
+ },
5669
+ required: ["path"]
5670
+ },
5671
+ async run(args) {
5672
+ const file = resolveInside(root, String(args.path ?? ""));
5673
+ const info = await stat(file).catch(() => null);
5674
+ if (!info?.isFile()) throw new Error(`${args.path} is not a file in the checkout.`);
5675
+ const raw = await readFile6(file, "utf8");
5676
+ const truncated = raw.length > MAX_FILE_BYTES;
5677
+ return {
5678
+ path: path5.relative(root, file),
5679
+ bytes: info.size,
5680
+ truncated,
5681
+ content: truncated ? raw.slice(0, MAX_FILE_BYTES) : raw
5682
+ };
5340
5683
  }
5341
- if (type === "error" || event.error) {
5342
- return { type: "error", payload: event };
5684
+ },
5685
+ {
5686
+ name: "search_code",
5687
+ description: "Search the workspace checkout for a string or regular expression, returning matching lines with their file and line number.",
5688
+ parameters: {
5689
+ type: "object",
5690
+ properties: {
5691
+ query: { type: "string", description: "Text or regular expression to find." },
5692
+ glob: { type: "string", description: "Optional glob to narrow the search, e.g. **/*.ts" }
5693
+ },
5694
+ required: ["query"]
5695
+ },
5696
+ async run(args) {
5697
+ const query = String(args.query ?? "");
5698
+ if (!query) throw new Error("search_code needs a query.");
5699
+ let re;
5700
+ try {
5701
+ re = new RegExp(query, "i");
5702
+ } catch {
5703
+ re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
5704
+ }
5705
+ const files = [];
5706
+ await walk(root, root, files, 0);
5707
+ const glob = args.glob ? String(args.glob) : void 0;
5708
+ const matches = [];
5709
+ for (const rel of files) {
5710
+ if (matches.length >= MAX_MATCHES) break;
5711
+ if (!matchesGlob(rel, glob)) continue;
5712
+ let content;
5713
+ try {
5714
+ content = await readFile6(path5.join(root, rel), "utf8");
5715
+ } catch {
5716
+ continue;
5717
+ }
5718
+ if (content.length > MAX_FILE_BYTES) content = content.slice(0, MAX_FILE_BYTES);
5719
+ const lines = content.split("\n");
5720
+ for (let i = 0; i < lines.length; i += 1) {
5721
+ if (matches.length >= MAX_MATCHES) break;
5722
+ if (re.test(lines[i])) {
5723
+ matches.push({ path: rel, line: i + 1, text: lines[i].trim().slice(0, 200) });
5724
+ }
5725
+ }
5726
+ }
5727
+ return { count: matches.length, truncated: matches.length >= MAX_MATCHES, matches };
5343
5728
  }
5344
- return { type: "status", payload: event };
5345
- } catch {
5346
- return { type: "text", payload: { text: trimmed } };
5347
5729
  }
5348
- },
5349
- extractFinal
5350
- };
5351
-
5352
- // src/architect/codex.ts
5353
- function mcpEndpoint(entry = process.argv[1], execPath = process.execPath) {
5354
- const mcpArgs = ["mcp"];
5355
- if (entry.endsWith(".ts") || entry.endsWith(".tsx")) {
5356
- return { command: "tsx", args: [entry, ...mcpArgs] };
5357
- }
5358
- return { command: execPath, args: [entry, ...mcpArgs] };
5359
- }
5360
- function mcpConfigArgs(endpoint, extraArgs = []) {
5361
- const args = [...endpoint.args, ...extraArgs];
5362
- return [
5363
- "-c",
5364
- `mcp_servers.hd.command=${JSON.stringify(endpoint.command)}`,
5365
- "-c",
5366
- `mcp_servers.hd.args=${JSON.stringify(args)}`
5367
- ];
5368
- }
5369
- function buildCodexArgs(opts) {
5370
- const shared = [
5371
- "--json",
5372
- "-c",
5373
- `model_reasoning_effort="${opts.effort}"`,
5374
- "-c",
5375
- 'sandbox_mode="read-only"',
5376
- "--skip-git-repo-check",
5377
- ...opts.mcp
5378
5730
  ];
5379
- if (opts.resumeSessionId) {
5380
- return ["exec", "resume", opts.resumeSessionId, ...shared, opts.prompt];
5381
- }
5382
- return ["exec", "--model", opts.model, "--sandbox", "read-only", ...shared, "-C", opts.cwd, opts.prompt];
5383
- }
5384
- function spawnArchitect(opts) {
5385
- const args = buildCodexArgs(opts);
5386
- const child = spawn4("codex", args, {
5387
- cwd: opts.cwd,
5388
- stdio: ["ignore", "pipe", "pipe"],
5389
- env: process.env
5390
- });
5391
- let sessionId = opts.resumeSessionId ?? null;
5392
- const chunks = [];
5393
- let stderr = "";
5394
- child.stderr.on("data", (data) => {
5395
- stderr += data.toString();
5396
- });
5397
- const queue = [];
5398
- let notify = null;
5399
- let finished = false;
5400
- const lines = createInterface2({ input: child.stdout });
5401
- lines.on("line", (line) => {
5402
- chunks.push(line);
5403
- const event = codexAdapter.parse(line);
5404
- if (!event) return;
5405
- const candidate = event.payload.session_id ?? event.payload.thread_id;
5406
- if (!sessionId && typeof candidate === "string") sessionId = candidate;
5407
- queue.push(event);
5408
- notify?.();
5409
- });
5410
- const done = new Promise(
5411
- (resolve) => {
5412
- child.on("close", (code) => {
5413
- finished = true;
5414
- notify?.();
5415
- resolve({
5416
- exitCode: code,
5417
- text: chunks.join("\n") + (stderr ? `
5418
- ${stderr}` : ""),
5419
- sessionId
5420
- });
5421
- });
5422
- child.on("error", () => {
5423
- finished = true;
5424
- notify?.();
5425
- resolve({ exitCode: 1, text: stderr || "codex failed to start", sessionId });
5426
- });
5427
- }
5428
- );
5429
- const events = {
5430
- async *[Symbol.asyncIterator]() {
5431
- while (true) {
5432
- while (queue.length > 0) yield queue.shift();
5433
- if (finished) return;
5434
- await new Promise((resolve) => {
5435
- notify = () => {
5436
- notify = null;
5437
- resolve();
5438
- };
5439
- });
5440
- }
5441
- }
5442
- };
5443
- return { args, events, kill: () => child.kill("SIGINT"), done };
5444
5731
  }
5445
5732
 
5446
5733
  // src/architect/plan.ts
5447
- var run2 = promisify3(execFile3);
5448
- var ARCHITECT_ROLE = "architect";
5449
- async function architectAgent(ctx) {
5450
- const { data, error } = await ctx.db.from("agents").select("*").eq("workspace_id", ctx.workspace.id).eq("role", ARCHITECT_ROLE).maybeSingle();
5451
- if (error) fail(error.message);
5452
- if (!data) {
5453
- fail(
5454
- "This workspace has no architect agent. Apply the migrations, or add one with:\n hd agent new Architect --provider codex --model gpt-5.6-sol"
5455
- );
5456
- }
5457
- if (!data.enabled) fail(`${data.display_name} is disabled. Enable it with \`hd agent set ${data.display_name} --enable\`.`);
5458
- return data;
5459
- }
5734
+ var run2 = promisify2(execFile2);
5460
5735
  async function repoCheckout(ctx) {
5461
5736
  const here = process.cwd();
5462
5737
  try {
5463
5738
  const { stdout } = await run2("git", ["remote", "get-url", "origin"], { cwd: here });
5464
- const origin = stdout.trim().toLowerCase();
5465
- const repo = ctx.workspace.repo.toLowerCase();
5466
- if (origin.includes(repo)) return here;
5739
+ if (stdout.trim().toLowerCase().includes(ctx.workspace.repo.toLowerCase())) return here;
5467
5740
  } catch {
5468
5741
  }
5469
- const root = path5.join(homedir4(), ".cache", "higherdev", "architect");
5742
+ const root = path6.join(homedir3(), ".cache", "higherdev", "architect");
5470
5743
  await mkdir2(root, { recursive: true });
5471
- const dir = path5.join(root, ctx.workspace.slug);
5744
+ const dir = path6.join(root, ctx.workspace.slug);
5472
5745
  try {
5473
5746
  await run2("git", ["-C", dir, "fetch", "--depth", "1", "origin", ctx.workspace.default_branch], {
5474
5747
  timeout: 12e4
@@ -5476,6 +5749,7 @@ async function repoCheckout(ctx) {
5476
5749
  await run2("git", ["-C", dir, "reset", "--hard", `origin/${ctx.workspace.default_branch}`], {
5477
5750
  timeout: 6e4
5478
5751
  });
5752
+ return dir;
5479
5753
  } catch {
5480
5754
  try {
5481
5755
  await run2(
@@ -5483,139 +5757,133 @@ async function repoCheckout(ctx) {
5483
5757
  ["repo", "clone", ctx.workspace.repo, dir, "--", "--depth", "1", "--branch", ctx.workspace.default_branch],
5484
5758
  { timeout: 3e5 }
5485
5759
  );
5486
- } catch (error) {
5487
- fail(
5488
- `Could not get a checkout of ${ctx.workspace.repo} (${error instanceof Error ? error.message : String(error)}).
5489
- Run \`hd plan\` from inside the repo, or check \`gh auth status\`.`
5490
- );
5760
+ return dir;
5761
+ } catch {
5762
+ return null;
5491
5763
  }
5492
5764
  }
5493
- return dir;
5494
5765
  }
5495
- function architectPrompt(opts) {
5766
+ function architectInstructions(opts) {
5496
5767
  const { ctx } = opts;
5497
5768
  return [
5498
- "You are the HigherDEV architect for the workspace below. You decide what should be built and",
5499
- "whether the platform is configured to build it. You do not write code: you file the work and the",
5500
- "orchestrator dispatches it to builders.",
5769
+ "You are the HigherDEV architect, running in the owner's terminal.",
5770
+ "You decide what should be built and whether the platform is configured to build it.",
5771
+ "You do not write code: you file the work, and the orchestrator dispatches it to builders.",
5501
5772
  "",
5502
- `Workspace: ${ctx.workspace.slug} (${ctx.workspace.repo}, branch ${ctx.workspace.default_branch})`,
5773
+ `Workspace: ${ctx.workspace.slug} (${ctx.workspace.repo}, branch ${ctx.workspace.default_branch}).`,
5503
5774
  `You are acting for ${ctx.email}.`,
5504
5775
  "",
5505
- "Tools: the `hd` MCP server is attached and is your only way to reach the platform. Call `status`",
5506
- "first, and `list_agents` before you assume who can build what. The repository is checked out at",
5507
- "your working directory, read only, so ground your assessment in the actual code.",
5776
+ "Your tools are the only source of truth about the board. Call status before you assume anything,",
5777
+ "and list_agents before you assume who can build what. Never invent tickets, agents, or state.",
5778
+ opts.hasRepo ? "A checkout of the repository is readable through list_files, read_file, and search_code. Ground any claim about the code in what you actually read." : "No checkout is available, so do not claim anything about the code you have not been told.",
5508
5779
  "",
5509
- "How to hand work over:",
5510
- "- Prefer creating one epic with a well-written spec. `create_epic` triggers the orchestrator,",
5511
- " which decomposes the spec into tickets itself and assigns them. That is the handoff.",
5512
- "- File individual tickets with `create_ticket` only for work that needs no decomposition.",
5780
+ "Handing work over:",
5781
+ "- Prefer one epic with a well-written spec. create_epic triggers the orchestrator, which",
5782
+ " decomposes the spec into tickets itself and assigns them. That is the handoff.",
5783
+ "- Use create_ticket only for work that needs no decomposition.",
5513
5784
  "- A ticket is one PR, one area, under about 400 changed lines, with acceptance criteria and a",
5514
5785
  " test expectation. Two live tickets must not share an area; chain them with blocked_by.",
5515
5786
  "",
5516
- "What only you can do: agent configuration. Model, effort, routing notes, prompt addenda,",
5517
- "conventions, budgets, pausing. The orchestrator has no action for any of it. If the work you are",
5518
- "planning needs a different builder lineup, change it with `update_agent` and say why.",
5519
- "",
5520
- opts.readOnly ? "This run is read-only. Propose the plan in your reply; do not expect any write tool to exist." : "When you are done, summarise what you filed and what the owner should look at.",
5521
- "Never use an em dash in anything you write.",
5522
- opts.addendum ? `
5523
- ${opts.addendum}` : "",
5524
- "",
5525
- "---",
5787
+ "Only you can change agent configuration: model, effort, routing notes, prompt addenda,",
5788
+ "conventions, budgets, pausing. The orchestrator has no action for any of it.",
5526
5789
  "",
5527
- opts.request
5528
- ].filter((line) => line !== null).join("\n");
5790
+ opts.readOnly ? "This session is read-only. Propose in your reply; the tools that change things are not loaded." : "Say what you filed and what the owner should look at.",
5791
+ "Answer conversationally and briefly. Lead with the answer. Never use an em dash."
5792
+ ].filter(Boolean).join("\n");
5529
5793
  }
5530
- async function runArchitect(opts) {
5794
+ async function openArchitect(opts) {
5531
5795
  const { ctx } = opts;
5532
- const agent = await architectAgent(ctx);
5533
- const connection = await codexConnection();
5534
- if (connection.state !== "connected" && !opts.allowApiBilling) {
5535
- fail(`${connectionSummary(connection)}
5536
- ${howToFix(connection) ?? ""}`.trim());
5537
- }
5538
- if (connection.state !== "connected") {
5539
- opts.onLine(c.yellow(`${connectionSummary(connection)} Continuing because --allow-api-billing was passed.`));
5540
- }
5541
- const cwd = await repoCheckout(ctx);
5542
- const config = await loadConfig();
5543
- const sessionKey = `architect_session_${ctx.workspace.slug}`;
5544
- const previous = config[sessionKey];
5545
- const resumeSessionId = opts.resume && typeof previous === "string" ? previous : null;
5546
- if (opts.resume && !resumeSessionId) fail("No architect session to resume in this workspace.");
5547
- const endpoint = mcpEndpoint();
5548
- const mcp = mcpConfigArgs(endpoint, [
5549
- ...opts.readOnly ? ["--read-only"] : [],
5550
- "--workspace",
5551
- ctx.workspace.slug
5552
- ]);
5796
+ const brain = await requireBrain();
5797
+ const repo = await repoCheckout(ctx);
5798
+ const platformTools = toolsFor(opts.readOnly ? "read" : "all").map((tool) => ({
5799
+ name: tool.name,
5800
+ description: tool.description,
5801
+ parameters: toolJsonSchema(tool),
5802
+ run: (args) => tool.run(ctx, args)
5803
+ }));
5804
+ return {
5805
+ brain,
5806
+ instructions: architectInstructions({
5807
+ ctx,
5808
+ readOnly: opts.readOnly,
5809
+ hasRepo: Boolean(repo)
5810
+ }),
5811
+ tools: [...platformTools, ...buildRepoTools(repo)],
5812
+ sessionId: newSessionId(),
5813
+ history: [],
5814
+ repo
5815
+ };
5816
+ }
5817
+ async function architectTurn(opts) {
5818
+ const { ctx, session } = opts;
5553
5819
  const { data: runRow, error: runError } = await ctx.db.from("runs").insert({
5554
5820
  workspace_id: ctx.workspace.id,
5555
5821
  ticket_id: null,
5556
- agent_id: agent.id,
5822
+ // No agent: the brain is the operator's own model, not a worker in this
5823
+ // workspace. The run exists so what it spends is still visible.
5824
+ agent_id: null,
5557
5825
  kind: "architect",
5558
- provider: agent.provider,
5559
- model: agent.model,
5826
+ provider: "codex",
5827
+ model: session.brain.model,
5560
5828
  status: "running",
5561
5829
  started_at: (/* @__PURE__ */ new Date()).toISOString(),
5830
+ session_id: session.sessionId,
5562
5831
  host: null
5563
5832
  }).select("id").single();
5564
5833
  if (runError) fail(runError.message);
5565
- const prompt2 = architectPrompt({
5566
- ctx,
5567
- request: opts.request,
5568
- addendum: agent.prompt_addendum,
5569
- readOnly: opts.readOnly
5570
- });
5571
- const spawned = spawnArchitect({
5572
- prompt: prompt2,
5573
- cwd,
5574
- model: agent.model,
5575
- effort: agent.effort ?? "high",
5576
- mcp,
5577
- resumeSessionId
5578
- });
5579
5834
  let seq = 0;
5580
- const pending = [];
5835
+ const events = [];
5836
+ const record = (type, payload) => events.push({ run_id: runRow.id, seq: seq++, type, payload });
5581
5837
  const flush = async () => {
5582
- if (pending.length === 0) return;
5583
- const batch = pending.splice(0, pending.length);
5584
- await ctx.db.from("run_events").insert(batch);
5838
+ if (events.length) await ctx.db.from("run_events").insert(events);
5585
5839
  };
5586
- const ticker = setInterval(() => void flush(), 2e3);
5587
- const interrupt = () => spawned.kill();
5588
- process.once("SIGINT", interrupt);
5589
- for await (const event of spawned.events) {
5590
- pending.push({ run_id: runRow.id, seq: seq++, type: event.type, payload: event.payload });
5591
- const [line] = transcriptLines([{ id: String(seq), type: event.type, payload: event.payload }]);
5592
- if (!line) continue;
5593
- if (line.kind === "status" && line.title === "init") continue;
5594
- if (line.kind === "text") opts.onLine(line.title);
5595
- else if (line.kind === "error") opts.onLine(c.red(line.title));
5596
- else opts.onLine(c.dim(` ${line.title}`));
5597
- if (line.body) opts.onLine(c.dim(` ${line.body}`));
5598
- }
5599
- clearInterval(ticker);
5600
- await flush();
5601
- process.off("SIGINT", interrupt);
5602
- const result = await spawned.done;
5603
- await ctx.db.from("runs").update({
5604
- status: result.exitCode === 0 ? "succeeded" : "failed",
5605
- ended_at: (/* @__PURE__ */ new Date()).toISOString(),
5606
- exit_code: result.exitCode,
5607
- session_id: result.sessionId,
5608
- summary: result.exitCode === 0 ? "Architect session" : `Codex exited ${result.exitCode}`
5609
- }).eq("id", runRow.id);
5610
- if (result.sessionId) await saveConfig({ [sessionKey]: result.sessionId });
5611
- await ctx.audit(ctx.workspace.id, "architect", { subject: agent.display_name });
5612
- if (result.exitCode !== 0 && !isJsonMode()) {
5613
- opts.onLine(c.red(`codex exited ${result.exitCode}`));
5614
- }
5615
- return { sessionId: result.sessionId, exitCode: result.exitCode, runId: runRow.id };
5840
+ try {
5841
+ const result = await runAgent({
5842
+ prompt: opts.request,
5843
+ instructions: session.instructions,
5844
+ history: session.history,
5845
+ tools: session.tools,
5846
+ model: session.brain.model,
5847
+ sessionId: session.sessionId,
5848
+ signal: opts.signal,
5849
+ onEvent: (event) => {
5850
+ if (event.kind === "tool") record("tool_use", { name: event.name, input: event.args });
5851
+ else if (event.kind === "tool_result") {
5852
+ record("tool_result", { name: event.name, ok: event.ok, content: event.detail });
5853
+ }
5854
+ opts.onEvent(event);
5855
+ }
5856
+ });
5857
+ if (result.text) record("text", { text: result.text });
5858
+ session.history.push({ role: "user", content: opts.request });
5859
+ session.history.push({ role: "assistant", content: result.text });
5860
+ await flush();
5861
+ await ctx.db.from("runs").update({
5862
+ status: "succeeded",
5863
+ ended_at: (/* @__PURE__ */ new Date()).toISOString(),
5864
+ exit_code: 0,
5865
+ turns: result.steps,
5866
+ summary: result.text.slice(0, 500) || "Architect turn"
5867
+ }).eq("id", runRow.id);
5868
+ await ctx.audit(ctx.workspace.id, "architect", { subject: describeBrain(session.brain) });
5869
+ return { text: result.text, runId: runRow.id, toolCalls: result.toolCalls };
5870
+ } catch (error) {
5871
+ const message = error instanceof Error ? error.message : String(error);
5872
+ record("error", { error: message });
5873
+ await flush();
5874
+ await ctx.db.from("runs").update({
5875
+ status: "failed",
5876
+ ended_at: (/* @__PURE__ */ new Date()).toISOString(),
5877
+ exit_code: 1,
5878
+ summary: message.slice(0, 500)
5879
+ }).eq("id", runRow.id);
5880
+ if (error instanceof ChatGptAuthError) fail(message);
5881
+ throw error;
5882
+ }
5616
5883
  }
5617
5884
 
5618
5885
  // src/architect/commands.ts
5886
+ init_connection();
5619
5887
  function slugOf6(command) {
5620
5888
  return command.optsWithGlobals().workspace;
5621
5889
  }
@@ -5641,25 +5909,38 @@ ${howToFix(connection) ?? ""}`.trim()
5641
5909
  );
5642
5910
  }
5643
5911
  function registerArchitectCommands(program) {
5644
- program.command("plan").argument("[request...]", "what to plan, assess, or change; omit for a conversation").description("the architect: assess the repo, shape the work, tune the platform").option("--read-only", "let it look and propose, but change nothing").option("--resume", "continue the last architect session in this workspace").option("--once", "run a single turn and exit, even without a request").option("--allow-api-billing", "run even when Codex would bill per token instead of the subscription").action(async function(request) {
5912
+ program.command("plan").argument("[request...]", "what to plan, assess, or change; omit for a conversation").description("the architect: assess the repo, shape the work, tune the platform").option("--read-only", "let it look and propose, but change nothing").option("--once", "run a single turn and exit, even without a request").action(async function(request) {
5645
5913
  const opts = this.opts();
5646
5914
  const ctx = await requireWorkspace(slugOf6(this));
5647
5915
  const first = request.join(" ").trim();
5648
5916
  const conversational = !first && !opts.once && process.stdin.isTTY && !isJsonMode();
5649
- const turn = async (ask, resume2) => {
5650
- const lines = [];
5651
- const result = await runArchitect({
5652
- ctx,
5653
- request: ask,
5654
- readOnly: Boolean(opts.readOnly),
5655
- resume: resume2,
5656
- allowApiBilling: Boolean(opts.allowApiBilling),
5657
- onLine: (text) => {
5658
- lines.push(text);
5659
- if (!isJsonMode()) out(text);
5917
+ const session = await openArchitect({ ctx, readOnly: Boolean(opts.readOnly) });
5918
+ let wroteText = false;
5919
+ const onEvent = (event) => {
5920
+ if (isJsonMode()) return;
5921
+ if (event.kind === "text") {
5922
+ process.stdout.write(event.delta);
5923
+ wroteText = true;
5924
+ return;
5925
+ }
5926
+ if (wroteText) {
5927
+ process.stdout.write("\n");
5928
+ wroteText = false;
5929
+ }
5930
+ if (event.kind === "tool") out(c.dim(` ${event.name}`));
5931
+ else if (!event.ok) out(c.red(` ${event.name} failed: ${event.detail}`));
5932
+ };
5933
+ const turn = async (ask) => {
5934
+ const result = await architectTurn({ ctx, session, request: ask, onEvent });
5935
+ if (!isJsonMode()) {
5936
+ if (wroteText) {
5937
+ process.stdout.write("\n");
5938
+ wroteText = false;
5939
+ } else if (!result.text) {
5940
+ out(c.dim("(no reply)"));
5660
5941
  }
5661
- });
5662
- return { result, lines };
5942
+ }
5943
+ return result;
5663
5944
  };
5664
5945
  if (!conversational) {
5665
5946
  let ask = first;
@@ -5668,30 +5949,24 @@ function registerArchitectCommands(program) {
5668
5949
  ask = await askLine("plan> ");
5669
5950
  if (!ask) fail("Nothing asked.");
5670
5951
  }
5671
- const { result, lines } = await turn(ask, Boolean(opts.resume));
5952
+ const result = await turn(ask);
5672
5953
  if (isJsonMode()) {
5673
- emit(
5674
- { ok: result.exitCode === 0, run_id: result.runId, session_id: result.sessionId, output: lines },
5675
- () => ""
5676
- );
5677
- return;
5678
- }
5679
- if (result.sessionId) {
5680
- out(c.dim(`
5681
- Continue with \`hd plan --resume "..."\`. Run ${result.runId.slice(0, 8)}.`));
5954
+ emit({ ok: true, run_id: result.runId, text: result.text, tool_calls: result.toolCalls }, () => "");
5682
5955
  }
5683
5956
  return;
5684
5957
  }
5685
- out(c.dim("Talking to the architect. Enter sends, blank line or /exit leaves, ctrl-c stops a turn."));
5686
- let resume = Boolean(opts.resume);
5958
+ out(
5959
+ c.dim(
5960
+ `${describeBrain(session.brain)}${session.repo ? ", repo readable" : ""}. Blank line or /exit leaves.`
5961
+ )
5962
+ );
5687
5963
  for (; ; ) {
5688
5964
  const ask = await askLine("\nplan> ");
5689
5965
  if (!ask || ask === "/exit" || ask === "/quit") {
5690
- out(c.dim('Left the architect. `hd plan --resume "..."` picks the thread back up.'));
5966
+ out(c.dim("Left the architect."));
5691
5967
  return;
5692
5968
  }
5693
- const { result } = await turn(ask, resume);
5694
- if (result.sessionId) resume = true;
5969
+ await turn(ask);
5695
5970
  }
5696
5971
  });
5697
5972
  program.command("mcp").description("serve the platform as MCP tools on stdio, for Codex, Claude Code, or Mel").option("--read-only", "expose only the tools that change nothing").option("--list", "print the tools instead of serving them").action(async function() {
@@ -5707,6 +5982,33 @@ Continue with \`hd plan --resume "..."\`. Run ${result.runId.slice(0, 8)}.`));
5707
5982
  }
5708
5983
  await serveMcp({ readOnly, workspace: slugOf6(this) });
5709
5984
  });
5985
+ program.command("brain").description("the model you talk to, and the subscription behind it").option("--model <model>", "which model to use").option("--effort <level>", "low, medium, or high").action(async function() {
5986
+ const opts = this.opts();
5987
+ if (opts.model || opts.effort) {
5988
+ const next = await setBrain({
5989
+ ...opts.model ? { model: String(opts.model) } : {},
5990
+ ...opts.effort ? { effort: String(opts.effort) } : {}
5991
+ });
5992
+ ok(describeBrain(next), { brain: next });
5993
+ return;
5994
+ }
5995
+ const config = await loadConfig();
5996
+ const chosen = config.brain ?? DEFAULT_BRAIN;
5997
+ const connection = await codexConnection();
5998
+ emit(
5999
+ { brain: chosen, connected: connection.state === "connected", codex: connection },
6000
+ () => [
6001
+ `${c.bold(describeBrain(chosen))}`,
6002
+ ...BRAINS.map(
6003
+ (brain) => ` ${brain.id === chosen.id ? c.blue("*") : " "} ${brain.label.padEnd(24)} ${c.dim(brain.billing)}`
6004
+ ),
6005
+ "",
6006
+ connection.state === "connected" ? c.green(connectionSummary(connection)) : `${c.yellow(connectionSummary(connection))}
6007
+ ${howToFix(connection) ?? ""}`,
6008
+ c.dim("Models: " + (brainById(chosen.id)?.models.join(", ") ?? ""))
6009
+ ].join("\n")
6010
+ );
6011
+ });
5710
6012
  program.command("connect").description("connect Codex to your ChatGPT subscription, so the architect costs nothing per token").option("--check", "report the state without starting a login").action(async function() {
5711
6013
  let connection = await codexConnection();
5712
6014
  if (connection.state === "connected" || this.opts().check) {
@@ -5727,19 +6029,34 @@ Run \`codex login\` in a terminal.`);
5727
6029
  report(connection);
5728
6030
  if (connection.state !== "connected") process.exitCode = 1;
5729
6031
  });
5730
- program.command("doctor").description("check the architect's Codex connection and how a run would bill").action(async function() {
6032
+ program.command("doctor").description("check the architect's ChatGPT connection and how a turn would bill").action(async function() {
5731
6033
  const connection = await codexConnection();
5732
- const endpoint = mcpEndpoint();
6034
+ const expiry = await tokenExpiry();
5733
6035
  emit(
5734
- { codex: connection, mcp: endpoint, config_args: mcpConfigArgs(endpoint) },
6036
+ { codex: connection, token_expires_in_hours: expiry },
5735
6037
  () => [
5736
- `${c.dim("codex")} ${connection.state === "connected" ? c.green(connectionSummary(connection)) : c.yellow(connectionSummary(connection))}`,
5737
- `${c.dim("mcp server")} ${endpoint.command} ${endpoint.args.join(" ")}`,
5738
- connection.state === "connected" ? c.dim("Architect runs bill the ChatGPT subscription, not per token.") : c.yellow(howToFix(connection) ?? "")
5739
- ].join("\n")
6038
+ `${c.dim("chatgpt")} ${connection.state === "connected" ? c.green(connectionSummary(connection)) : c.yellow(connectionSummary(connection))}`,
6039
+ expiry == null ? "" : `${c.dim("session")} renews automatically, valid for ${expiry}h`,
6040
+ connection.state === "connected" ? c.dim("The architect calls the ChatGPT backend directly on your subscription. No Codex process, no per-token billing.") : c.yellow(howToFix(connection) ?? "")
6041
+ ].filter(Boolean).join("\n")
5740
6042
  );
5741
6043
  });
5742
6044
  }
6045
+ async function tokenExpiry() {
6046
+ try {
6047
+ const { readFile: readFile8 } = await import("fs/promises");
6048
+ const path8 = await import("path");
6049
+ const { codexHome: codexHome2 } = await Promise.resolve().then(() => (init_connection(), connection_exports));
6050
+ const { msUntilExpiry: msUntilExpiry2 } = await Promise.resolve().then(() => (init_chatgpt(), chatgpt_exports));
6051
+ const raw = await readFile8(path8.join(codexHome2(), "auth.json"), "utf8");
6052
+ const token = JSON.parse(raw).tokens?.access_token;
6053
+ if (!token) return null;
6054
+ const ms = msUntilExpiry2(token);
6055
+ return Number.isFinite(ms) ? Math.round(ms / 36e5) : null;
6056
+ } catch {
6057
+ return null;
6058
+ }
6059
+ }
5743
6060
 
5744
6061
  // src/tui/commands.ts
5745
6062
  init_json();