@neta-art/cohub-cli 9.1.0 → 9.1.2

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.
@@ -1,5 +1,6 @@
1
1
  import { createClient } from "../client.js";
2
2
  import { formatLocalDateTime, handleHttp, json as outJson, jsonRequested, table } from "../output.js";
3
+ import { SESSION_STATUS_COLUMN } from "../session-status.js";
3
4
  function parseInteger(value, name, min) {
4
5
  const parsed = Number.parseInt(value, 10);
5
6
  if (!Number.isSafeInteger(parsed) || parsed < min) {
@@ -34,6 +35,7 @@ export function registerMe(program) {
34
35
  { key: "id", label: "ID" },
35
36
  { key: "spaceId", label: "Space" },
36
37
  { key: "title", label: "Title" },
38
+ SESSION_STATUS_COLUMN,
37
39
  { key: "totalMessages", label: "Messages" },
38
40
  { key: "createdAt", label: "Created" },
39
41
  ]);
@@ -4,6 +4,7 @@ import { readSentTurns, readSessionTurnOrigin, resolveCohubEnvironment } from "@
4
4
  import { uploadAvatarAsset, uploadChatImageAsset } from "../avatar.js";
5
5
  import { createClient } from "../client.js";
6
6
  import { table, json as outJson, jsonRequested, ok, error, handleHttp, formatEpochMs, truncateText } from "../output.js";
7
+ import { SESSION_STATUS_COLUMN } from "../session-status.js";
7
8
  import { resolveSpace } from "../space.js";
8
9
  import { uploadLocalFiles } from "../space-files/upload.js";
9
10
  import { registerSpaceCommerce } from "./space-commerce.js";
@@ -1329,6 +1330,7 @@ function registerSessions(spacesCmd) {
1329
1330
  table(result.sessions, [
1330
1331
  { key: "id", label: "ID" },
1331
1332
  { key: "title", label: "Title" },
1333
+ SESSION_STATUS_COLUMN,
1332
1334
  { key: "totalMessages", label: "Messages" },
1333
1335
  { key: "createdAt", label: "Created" },
1334
1336
  ]);
@@ -1376,6 +1378,7 @@ function registerSessions(spacesCmd) {
1376
1378
  table([result.session], [
1377
1379
  { key: "id", label: "ID" },
1378
1380
  { key: "title", label: "Title" },
1381
+ SESSION_STATUS_COLUMN,
1379
1382
  { key: "totalMessages", label: "Messages" },
1380
1383
  { key: "totalToolCalls", label: "Tool Calls" },
1381
1384
  { key: "createdAt", label: "Created" },
@@ -0,0 +1,3 @@
1
+ export declare const isFetchFailure: (error: unknown) => error is Error;
2
+ export declare function fetchFailureReason(error: unknown): string | null;
3
+ export declare function describeError(error: unknown): string;
@@ -0,0 +1,22 @@
1
+ const text = (value) => (typeof value === "string" && value ? value : null);
2
+ export const isFetchFailure = (error) => error instanceof Error && error.message === "fetch failed";
3
+ export function fetchFailureReason(error) {
4
+ if (!isFetchFailure(error) || typeof error.cause !== "object" || !error.cause)
5
+ return null;
6
+ const cause = error.cause;
7
+ const message = text(cause.message);
8
+ const code = text(cause.code);
9
+ const hostname = text(cause.hostname);
10
+ const parts = [
11
+ code && !message?.includes(code) ? code : null,
12
+ hostname && !message?.includes(hostname) ? `host: ${hostname}` : null,
13
+ message,
14
+ ].filter(Boolean);
15
+ return parts.length > 0 ? parts.join(" · ") : null;
16
+ }
17
+ export function describeError(error) {
18
+ if (!(error instanceof Error))
19
+ return String(error);
20
+ const reason = fetchFailureReason(error);
21
+ return reason ? `${error.message} (${reason})` : error.message;
22
+ }
package/dist/output.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import process from "node:process";
2
2
  import { extractBillingPayload } from "@neta-art/cohub";
3
+ import { fetchFailureReason, isFetchFailure } from "./network-error.js";
3
4
  /**
4
5
  * Timestamp → local ISO-style date and time with the system's UTC offset.
5
6
  *
@@ -146,16 +147,10 @@ function debugErrorMetaFromBody(body) {
146
147
  return items;
147
148
  }
148
149
  function fetchFailureDetail(e) {
149
- if (!(e instanceof Error) || e.message !== "fetch failed")
150
+ if (!isFetchFailure(e))
150
151
  return null;
151
- const cause = e.cause;
152
- const code = typeof cause?.code === "string" ? cause.code : null;
153
- const hostname = typeof cause?.hostname === "string" ? cause.hostname : null;
154
- const message = typeof cause?.message === "string" ? cause.message : null;
155
- const parts = [code, hostname && `host: ${hostname}`, message].filter(Boolean);
156
- return parts.length > 0
157
- ? `Network request failed (${parts.join(" · ")}). Check DNS/proxy/firewall settings and try again.`
158
- : "Network request failed. Check DNS/proxy/firewall settings and try again.";
152
+ const reason = fetchFailureReason(e);
153
+ return `Network request failed${reason ? ` (${reason})` : ""}. Check DNS/proxy/firewall settings and try again.`;
159
154
  }
160
155
  function errorPresentationFromHttpError(e) {
161
156
  const httpError = e;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { RUNTIME_MAX_FRAME_BYTES, RUNTIME_PROTOCOL_VERSION, runtimeCommandSchema, runtimeReadySchema, runtimeNativeStopSchema, } from "@neta-art/cohub";
4
+ import { describeError } from "../network-error.js";
4
5
  import { executeTurn } from "./native/execution.js";
5
6
  import { ContextRequiredError } from "./native/results.js";
6
7
  import { ProcessCleanupUncertainError } from "./process-group.js";
@@ -579,7 +580,7 @@ async function connect(options) {
579
580
  error: serializeDiagnosticError(error),
580
581
  }, context);
581
582
  try {
582
- emit({ type: "turn.error", message: error instanceof Error ? error.message : String(error), uncertain: !!execution.result || frame.resumeOnly === true || error instanceof ProcessCleanupUncertainError });
583
+ emit({ type: "turn.error", message: describeError(error), uncertain: !!execution.result || frame.resumeOnly === true || error instanceof ProcessCleanupUncertainError });
583
584
  }
584
585
  catch {
585
586
  // Native files remain for recovery.
@@ -2,7 +2,7 @@ import { access, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { delimiter, join, resolve, win32 } from "node:path";
4
4
  import { JsonRpcProcess, record } from "./json-rpc.js";
5
- import { codexModelCatalog } from "./model-catalog.js";
5
+ import { codexModelCatalog, piModelCatalog } from "./model-catalog.js";
6
6
  import { downloadPublicImage } from "../safe-remote-image.js";
7
7
  export function harnessExecutableCandidates(binary, cwd, path, platform = process.platform, pathExt = process.env.PATHEXT) {
8
8
  const windows = platform === "win32";
@@ -68,11 +68,8 @@ export async function discoverHarnesses(harnesses, options, cwd) {
68
68
  try {
69
69
  if (harness === "pi") {
70
70
  const result = await rpc.request("get_available_models");
71
- for (const value of array(result.models)) {
72
- const model = record(value);
73
- if (model.id && model.provider)
74
- models.push({ harness, id: text(model.id), provider: text(model.provider), name: text(model.name) || text(model.id) });
75
- }
71
+ const state = await rpc.request("get_state");
72
+ models.push(...piModelCatalog(array(result.models), state));
76
73
  }
77
74
  else {
78
75
  await initializeCodex(rpc);
@@ -1,6 +1,14 @@
1
1
  import type { RuntimeCapabilities } from "@neta-art/cohub";
2
2
  import { type JsonRecord } from "./json-rpc.js";
3
3
  type Model = RuntimeCapabilities["models"][number];
4
+ /**
5
+ * Publish only picker metadata, never provider credentials or request configuration.
6
+ * `state` is a fresh Pi's `get_state`: its model is what Pi runs when Cohub requests
7
+ * nothing, and its level is resolved and clamped for that model only. Pi's levels for
8
+ * other models live in its settings, which RPC does not expose; those models fall back
9
+ * to the picker's rule, and Cohub always sends the chosen level explicitly.
10
+ */
11
+ export declare function piModelCatalog(entries: unknown[], state: JsonRecord): Model[];
4
12
  /** Codex's built-in catalog is not authoritative for an arbitrary custom provider. */
5
13
  export declare function codexModelCatalog(configResponse: JsonRecord, entries: unknown[]): Model[];
6
14
  export {};
@@ -1,4 +1,50 @@
1
1
  import { record } from "./json-rpc.js";
2
+ const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
3
+ const thinkingLevel = (value) => thinkingLevels.includes(value) ? value : undefined;
4
+ /**
5
+ * Publish only picker metadata, never provider credentials or request configuration.
6
+ * `state` is a fresh Pi's `get_state`: its model is what Pi runs when Cohub requests
7
+ * nothing, and its level is resolved and clamped for that model only. Pi's levels for
8
+ * other models live in its settings, which RPC does not expose; those models fall back
9
+ * to the picker's rule, and Cohub always sends the chosen level explicitly.
10
+ */
11
+ export function piModelCatalog(entries, state) {
12
+ const current = record(state.model);
13
+ const currentLevel = thinkingLevel(state.thinkingLevel);
14
+ return entries.flatMap((value) => {
15
+ const model = record(value);
16
+ if (typeof model.id !== "string" || !model.id || typeof model.provider !== "string" || !model.provider)
17
+ return [];
18
+ const map = record(model.thinkingLevelMap);
19
+ const thinkingLevelMap = Object.fromEntries(thinkingLevels.flatMap((level) => map[level] === null || typeof map[level] === "string" ? [[level, map[level]]] : []));
20
+ const isDefault = current.provider === model.provider && current.id === model.id;
21
+ return [{
22
+ harness: "pi", id: model.id, provider: model.provider, name: typeof model.name === "string" ? model.name : model.id,
23
+ ...(isDefault ? { isDefault } : {}),
24
+ ...(typeof model.reasoning === "boolean" ? { reasoning: model.reasoning } : {}),
25
+ ...(isDefault && currentLevel ? { defaultThinkingLevel: currentLevel } : {}),
26
+ ...(Object.keys(thinkingLevelMap).length ? { thinkingLevelMap } : {}),
27
+ }];
28
+ });
29
+ }
30
+ function codexThinking(model, configuredEffort) {
31
+ if (!Array.isArray(model.supportedReasoningEfforts))
32
+ return {};
33
+ const efforts = new Set(model.supportedReasoningEfforts.map((entry) => record(entry).reasoningEffort));
34
+ const thinkingLevelMap = Object.fromEntries(thinkingLevels.map((level) => {
35
+ const effort = level === "off" ? "none" : level;
36
+ return [level, efforts.has(effort) ? effort : null];
37
+ }));
38
+ const normalize = (value) => thinkingLevel(value === "none" ? "off" : value);
39
+ const configured = normalize(configuredEffort);
40
+ const fallback = normalize(model.defaultReasoningEffort);
41
+ const defaultThinkingLevel = [configured, fallback].find((level) => level && thinkingLevelMap[level] != null);
42
+ return {
43
+ reasoning: thinkingLevels.some((level) => level !== "off" && thinkingLevelMap[level] != null),
44
+ thinkingLevelMap,
45
+ ...(defaultThinkingLevel ? { defaultThinkingLevel } : {}),
46
+ };
47
+ }
2
48
  /** Codex's built-in catalog is not authoritative for an arbitrary custom provider. */
3
49
  export function codexModelCatalog(configResponse, entries) {
4
50
  const config = record(configResponse.config);
@@ -11,9 +57,11 @@ export function codexModelCatalog(configResponse, entries) {
11
57
  const id = typeof model.model === "string" ? model.model : typeof model.id === "string" ? model.id : null;
12
58
  if (!id || model.hidden)
13
59
  return [];
14
- return [{ harness: "codex", provider, id, name: typeof model.displayName === "string" ? model.displayName : id }];
60
+ // A configured model replaces the catalog's own default.
61
+ const isDefault = configuredModel ? id === configuredModel : model.isDefault === true;
62
+ return [{ harness: "codex", provider, id, name: typeof model.displayName === "string" ? model.displayName : id, ...(isDefault ? { isDefault } : {}), ...codexThinking(model, config.model_reasoning_effort) }];
15
63
  });
16
64
  if (configuredModel && !models.some((model) => model.id === configuredModel))
17
- models.unshift({ harness: "codex", provider, id: configuredModel, name: configuredModel });
65
+ models.unshift({ harness: "codex", provider, id: configuredModel, name: configuredModel, isDefault: true });
18
66
  return models;
19
67
  }
@@ -1,5 +1,6 @@
1
1
  import { imageForPi } from "../harness.js";
2
2
  import { JsonRpcProcess, record, RpcProcessClosedError } from "../json-rpc.js";
3
+ import { describeError } from "../../network-error.js";
3
4
  import { ProcessCleanupUncertainError } from "../process-group.js";
4
5
  import { serializeDiagnosticError } from "../diagnostics.js";
5
6
  import { openPrivateServer } from "./codex-link.js";
@@ -164,7 +165,7 @@ async function runPi(executor, turn, context) {
164
165
  await closeHost(host, null).catch(() => undefined);
165
166
  throw error;
166
167
  }
167
- message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : error instanceof Error ? error.message : String(error) };
168
+ message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
168
169
  }
169
170
  return { session, resume: prepared.resume, message, host };
170
171
  }
@@ -293,7 +294,7 @@ async function runCodex(executor, turn, context) {
293
294
  escalation = setTimeout(() => void host?.close().catch(() => undefined), ABORT_ESCALATION_MS);
294
295
  };
295
296
  signal.addEventListener("abort", abort, { once: true });
296
- codex.request("turn/start", { threadId, clientUserMessageId: input.turnId, input: content, ...(input.thinkingLevel ? { effort: input.thinkingLevel } : {}) })
297
+ codex.request("turn/start", { threadId, clientUserMessageId: input.turnId, input: content, ...(input.thinkingLevel ? { effort: input.thinkingLevel === "off" ? "none" : input.thinkingLevel } : {}) })
297
298
  .then((result) => { nativeTurnId ??= text(record(result.turn).id) || null; if (signal.aborted)
298
299
  interrupt(); })
299
300
  .catch(finish);
@@ -306,7 +307,7 @@ async function runCodex(executor, turn, context) {
306
307
  await closeHost(host, null).catch(() => undefined);
307
308
  throw error;
308
309
  }
309
- message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : error instanceof Error ? error.message : String(error) };
310
+ message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
310
311
  }
311
312
  return { session, resume: prepared.resume, message, host };
312
313
  }
@@ -1,22 +1,26 @@
1
+ import { type LookupFunction } from "node:net";
1
2
  export declare const REMOTE_IMAGE_MAX_BYTES: number;
2
3
  export declare const REMOTE_IMAGE_TIMEOUT_MS = 15000;
3
4
  type ResolvedAddress = {
4
5
  address: string;
5
6
  family: 4 | 6;
6
7
  };
8
+ type PinnedAddresses = readonly [ResolvedAddress, ...ResolvedAddress[]];
7
9
  type Lookup = (hostname: string) => Promise<readonly ResolvedAddress[]>;
8
10
  type RemoteResponse = {
9
11
  status: number;
10
12
  headers: Headers;
11
13
  bytes: Uint8Array;
12
14
  };
13
- type Requester = (url: URL, address: ResolvedAddress, timeoutMs: number, maxBytes: number) => Promise<RemoteResponse>;
15
+ type Requester = (url: URL, addresses: PinnedAddresses, timeoutMs: number, maxBytes: number) => Promise<RemoteResponse>;
14
16
  export type RemoteImageDownloadOptions = {
15
17
  lookup?: Lookup;
16
18
  requester?: Requester;
17
19
  maxBytes?: number;
18
20
  timeoutMs?: number;
19
21
  };
22
+ /** Async like `dns.lookup`: a synchronous answer crashes TLS on an immediate connect failure. */
23
+ export declare function pinnedLookup(addresses: PinnedAddresses): LookupFunction;
20
24
  export declare function downloadPublicImage(input: string, options?: RemoteImageDownloadOptions): Promise<{
21
25
  bytes: Uint8Array;
22
26
  mimeType: string;
@@ -34,15 +34,22 @@ function responseHeaders(input) {
34
34
  }
35
35
  return headers;
36
36
  }
37
- function requestPinned(url, address, timeoutMs, maxBytes) {
37
+ /** Async like `dns.lookup`: a synchronous answer crashes TLS on an immediate connect failure. */
38
+ export function pinnedLookup(addresses) {
39
+ const [first] = addresses;
40
+ return (_hostname, options, callback) => {
41
+ process.nextTick(() => options.all
42
+ ? callback(null, [...addresses])
43
+ : callback(null, first.address, first.family));
44
+ };
45
+ }
46
+ function requestPinned(url, addresses, timeoutMs, maxBytes) {
38
47
  return new Promise((resolve, reject) => {
39
48
  const client = url.protocol === "https:" ? https : http;
40
49
  const request = client.request(url, {
41
50
  agent: false,
42
51
  headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
43
- lookup: (_hostname, _options, callback) => {
44
- callback(null, address.address, address.family);
45
- },
52
+ lookup: pinnedLookup(addresses),
46
53
  }, (response) => {
47
54
  const status = response.statusCode ?? 0;
48
55
  const headers = responseHeaders(response.headers);
@@ -116,11 +123,12 @@ async function resolvePublicUrl(value, lookup, deadline) {
116
123
  const addresses = isIP(hostname)
117
124
  ? [{ address: hostname, family: isIP(hostname) }]
118
125
  : await withDeadline(lookup(hostname), deadline);
119
- if (addresses.length === 0 ||
126
+ const [first, ...rest] = addresses;
127
+ if (!first ||
120
128
  addresses.some((entry) => !isPublicBoardRemoteAddress(entry.address))) {
121
129
  throw new Error("Image URL resolves to a private address");
122
130
  }
123
- return { url, address: addresses[0] };
131
+ return { url, addresses: [first, ...rest] };
124
132
  }
125
133
  export async function downloadPublicImage(input, options = {}) {
126
134
  const lookup = options.lookup ?? defaultLookup;
@@ -129,8 +137,8 @@ export async function downloadPublicImage(input, options = {}) {
129
137
  const deadline = Date.now() + (options.timeoutMs ?? REMOTE_IMAGE_TIMEOUT_MS);
130
138
  let current = input;
131
139
  for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
132
- const { url, address } = await resolvePublicUrl(current, lookup, deadline);
133
- const response = await withDeadline(requester(url, address, remainingMs(deadline), maxBytes), deadline);
140
+ const { url, addresses } = await resolvePublicUrl(current, lookup, deadline);
141
+ const response = await withDeadline(requester(url, addresses, remainingMs(deadline), maxBytes), deadline);
134
142
  if (REDIRECT_STATUSES.has(response.status)) {
135
143
  const location = response.headers.get("location");
136
144
  if (!location)
@@ -0,0 +1,6 @@
1
+ import type { SessionRecord } from "@neta-art/cohub";
2
+ import type { Column } from "./output.js";
3
+ type SessionStatusFields = Pick<SessionRecord, "activeTurn" | "lastTurnIssue">;
4
+ export declare function sessionStatusText(session: Partial<SessionStatusFields>): string;
5
+ export declare const SESSION_STATUS_COLUMN: Column;
6
+ export {};
@@ -0,0 +1,14 @@
1
+ export function sessionStatusText(session) {
2
+ const turn = session.activeTurn;
3
+ if (turn)
4
+ return turn.status === "abort_requested" ? "stopping" : turn.status;
5
+ const issue = session.lastTurnIssue;
6
+ if (issue?.status === "failed")
7
+ return "failed";
8
+ return issue?.reason === "stale_active_recovered" ? "interrupted" : "";
9
+ }
10
+ export const SESSION_STATUS_COLUMN = {
11
+ key: "activeTurn",
12
+ label: "Status",
13
+ format: (_value, row) => sessionStatusText(row),
14
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "9.1.0",
3
+ "version": "9.1.2",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,11 +18,11 @@
18
18
  "NOTICE"
19
19
  ],
20
20
  "dependencies": {
21
+ "@neta-art/cohub": "10.0.0",
21
22
  "@neta-art/generation": "^0.1.30",
22
23
  "commander": "^15.0.0",
23
24
  "sharp": "^0.35.4",
24
- "ws": "^8.21.3",
25
- "@neta-art/cohub": "9.0.0"
25
+ "ws": "^8.21.3"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@fontsource/geist": "^5.3.0",
32
32
  "@fontsource/geist-mono": "^5.3.0",
33
- "@types/node": "^26.4.0",
33
+ "@types/node": "26.6.4",
34
34
  "@types/ws": "^8.18.1",
35
35
  "pixi.js": "^8.20.1",
36
36
  "tsdown": "^0.22.14",