@fastino-ai/pioneer-cli 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import React, { useState, useEffect, useRef } from "react";
8
- import { render, Box, Text, useApp, useInput, useStdin, Static } from "ink";
8
+ import { render, Box, Text, useApp, useInput, useStdin, useStdout, Static } from "ink";
9
9
  import Spinner from "ink-spinner";
10
10
  import TextInput from "ink-text-input";
11
11
  import * as fs from "fs";
@@ -15,6 +15,8 @@ import {
15
15
  getApiKey,
16
16
  getBaseUrl,
17
17
  getMleModel,
18
+ getLastAgentConversationId,
19
+ setLastAgentConversationId,
18
20
  saveConfig,
19
21
  clearApiKey,
20
22
  getHfToken,
@@ -165,6 +167,8 @@ const BOOLEAN_FLAGS = new Set([
165
167
  "format-results",
166
168
  "reasoning-trace",
167
169
  "use-meta-felix",
170
+ "json",
171
+ "all",
168
172
  ]);
169
173
 
170
174
  function parseArgs(argv: string[]): {
@@ -274,24 +278,28 @@ interface TelemetryPromptProps {
274
278
 
275
279
  const TelemetryPrompt: React.FC<TelemetryPromptProps> = ({ onComplete }) => {
276
280
  const [selected, setSelected] = useState<"yes" | "no">("yes");
281
+ const { isRawModeSupported } = useStdin();
277
282
 
278
- useInput((input, key) => {
279
- if (key.leftArrow || key.rightArrow) {
280
- setSelected((s) => (s === "yes" ? "no" : "yes"));
281
- }
282
- if (key.return) {
283
- setTelemetryEnabled(selected === "yes");
284
- onComplete();
285
- }
286
- if (input === "y" || input === "Y") {
287
- setTelemetryEnabled(true);
288
- onComplete();
289
- }
290
- if (input === "n" || input === "N") {
291
- setTelemetryEnabled(false);
292
- onComplete();
293
- }
294
- });
283
+ useInput(
284
+ (input, key) => {
285
+ if (key.leftArrow || key.rightArrow) {
286
+ setSelected((s) => (s === "yes" ? "no" : "yes"));
287
+ }
288
+ if (key.return) {
289
+ setTelemetryEnabled(selected === "yes");
290
+ onComplete();
291
+ }
292
+ if (input === "y" || input === "Y") {
293
+ setTelemetryEnabled(true);
294
+ onComplete();
295
+ }
296
+ if (input === "n" || input === "N") {
297
+ setTelemetryEnabled(false);
298
+ onComplete();
299
+ }
300
+ },
301
+ { isActive: isRawModeSupported }
302
+ );
295
303
 
296
304
  return (
297
305
  <Box flexDirection="column" paddingX={1}>
@@ -725,8 +733,12 @@ function toHistoryMessages(
725
733
  return items
726
734
  .filter((item) => Boolean(item && typeof item.content === "string" && typeof item.role === "string"))
727
735
  .map((item) => {
736
+ const role =
737
+ item.role === "user" || item.role === "assistant" || item.role === "tool"
738
+ ? item.role
739
+ : "assistant";
728
740
  const message: HistoryMessage = {
729
- role: item.role as "user" | "assistant" | "tool" | string,
741
+ role,
730
742
  content: item.content,
731
743
  };
732
744
 
@@ -735,7 +747,20 @@ function toHistoryMessages(
735
747
  message.tool_call_id = sessionMessage.tool_call_id;
736
748
  }
737
749
  if (sessionMessage.tool_calls && Array.isArray(sessionMessage.tool_calls)) {
738
- message.tool_calls = sessionMessage.tool_calls.filter((call) => Boolean(call));
750
+ message.tool_calls = sessionMessage.tool_calls
751
+ .filter(
752
+ (call): call is { id: string; name: string; args: Record<string, unknown> } =>
753
+ Boolean(call) &&
754
+ typeof call.id === "string" &&
755
+ typeof call.name === "string" &&
756
+ typeof call.args === "object" &&
757
+ call.args !== null
758
+ )
759
+ .map((call) => ({
760
+ id: call.id,
761
+ name: call.name,
762
+ args: call.args,
763
+ }));
739
764
  }
740
765
 
741
766
  return message;
@@ -757,6 +782,11 @@ function AgentInteractiveCommand({ message, conversationId, history }: AgentInte
757
782
  const finish = (code: number) => {
758
783
  setTimeout(() => process.exit(code), 300);
759
784
  };
785
+ const persistConversation = (nextConversationId?: string) => {
786
+ if (nextConversationId) {
787
+ setLastAgentConversationId(nextConversationId);
788
+ }
789
+ };
760
790
  const getLatestAssistantContent = (
761
791
  messages: HistoryMessage[] | undefined
762
792
  ): string => {
@@ -838,6 +868,9 @@ function AgentInteractiveCommand({ message, conversationId, history }: AgentInte
838
868
  throw new Error(lastError || result.error || "Agent fallback request failed.");
839
869
  }
840
870
  setStream(result.data?.answer ?? "");
871
+ if (result.data?.conversation_id) {
872
+ persistConversation(result.data.conversation_id);
873
+ }
841
874
  if (!result.data?.answer) {
842
875
  setError("Agent returned no response content.");
843
876
  setState("error");
@@ -982,6 +1015,7 @@ function AgentInteractiveCommand({ message, conversationId, history }: AgentInte
982
1015
  }
983
1016
  doneHistory.current = messages;
984
1017
  wsSessionId = sessionId;
1018
+ persistConversation(sessionId);
985
1019
  },
986
1020
  }, {
987
1021
  history: mergedHistory,
@@ -1089,59 +1123,244 @@ function formatAutoAgentTurn(turn: AutoAgentTurn): string {
1089
1123
  return `${prefix}${turn.content}`;
1090
1124
  }
1091
1125
 
1092
- function AutoAgentInteractiveSession({
1126
+ export function AutoAgentInteractiveSession({
1093
1127
  conversationId: initialConversationId,
1094
1128
  history,
1095
1129
  mode,
1096
1130
  firstMessage,
1131
+ allowSessionCreation = true,
1097
1132
  }: {
1098
1133
  conversationId?: string;
1099
1134
  history?: api.AgentChatHistoryItem[];
1100
1135
  mode: "standard" | "research";
1101
1136
  firstMessage?: string;
1137
+ allowSessionCreation?: boolean;
1102
1138
  }) {
1139
+ const { isRawModeSupported } = useStdin();
1103
1140
  const [input, setInput] = useState("");
1104
1141
  const [isLoading, setIsLoading] = useState(false);
1105
1142
  const [error, setError] = useState("");
1106
1143
  const [statusHint, setStatusHint] = useState("Start typing...");
1107
1144
  const [conversationId, setConversationId] = useState(initialConversationId);
1108
1145
  const [historyState, setHistoryState] = useState<api.AgentChatHistoryItem[]>(history ?? []);
1146
+ const normalizeTurnRole = (role: string): AutoAgentTurnRole => {
1147
+ const normalized = role?.trim().toLowerCase();
1148
+ return normalized === "user" || normalized === "human" ? "user" : "assistant";
1149
+ };
1150
+ const [isHistoryLoading, setIsHistoryLoading] = useState(
1151
+ Boolean(initialConversationId) && history === undefined
1152
+ );
1153
+ const [isResumeSessionUsable, setIsResumeSessionUsable] = useState(true);
1109
1154
  const [turns, setTurns] = useState<AutoAgentTurn[]>(
1110
- (history ?? []).map((entry) =>
1111
- entry.role === "assistant"
1112
- ? { role: "assistant", content: entry.content }
1113
- : { role: "user", content: entry.content }
1114
- )
1155
+ (history ?? []).map((entry) => ({
1156
+ role: normalizeTurnRole(entry.role),
1157
+ content: entry.content,
1158
+ }))
1115
1159
  );
1160
+ const [inputHistory, setInputHistory] = useState<string[]>([]);
1161
+ const [inputHistoryIndex, setInputHistoryIndex] = useState(-1);
1116
1162
  const didSeedFirstMessage = useRef(false);
1163
+ const setInputValue = (nextValue: string) => {
1164
+ setInput(nextValue);
1165
+ setInputHistoryIndex(-1);
1166
+ };
1167
+ const normalizeSessionUserHistory = (items: api.AgentChatHistoryItem[]) =>
1168
+ items
1169
+ .filter((message) => {
1170
+ const normalizedRole = message.role?.trim().toLowerCase();
1171
+ return (
1172
+ (normalizedRole === "user" || normalizedRole === "human") &&
1173
+ typeof message.content === "string"
1174
+ );
1175
+ })
1176
+ .map((message) => message.content.trim())
1177
+ .filter((content): content is string => content.length > 0);
1178
+ const mapHistoryToTurns = (items: api.AgentChatHistoryItem[]) =>
1179
+ items.map((entry) => ({
1180
+ role: normalizeTurnRole(entry.role),
1181
+ content: entry.content,
1182
+ }));
1183
+
1184
+ const commitInputToHistory = (message: string) => {
1185
+ const normalized = message.trim();
1186
+ if (!normalized) {
1187
+ return;
1188
+ }
1189
+
1190
+ setInputHistory((previous) => {
1191
+ if (previous.length > 0 && previous[previous.length - 1] === normalized) {
1192
+ return previous;
1193
+ }
1194
+ return [...previous, normalized];
1195
+ });
1196
+ };
1197
+
1198
+ useEffect(() => {
1199
+ const restoredHistory = history ?? [];
1200
+ setHistoryState(restoredHistory);
1201
+ setTurns(mapHistoryToTurns(restoredHistory));
1202
+ setInputHistory(normalizeSessionUserHistory(restoredHistory));
1203
+ if (history !== undefined || !initialConversationId) {
1204
+ setIsHistoryLoading(false);
1205
+ }
1206
+ }, [history]);
1207
+
1208
+ useEffect(() => {
1209
+ setTurns(mapHistoryToTurns(historyState));
1210
+ }, [historyState]);
1211
+
1212
+ useEffect(() => {
1213
+ if (!initialConversationId || history !== undefined) {
1214
+ setIsHistoryLoading(false);
1215
+ return;
1216
+ }
1217
+
1218
+ let isActive = true;
1219
+ const hydrateSessionHistory = async () => {
1220
+ const result = await api.getAgentSession(initialConversationId);
1221
+ if (!isActive) {
1222
+ return;
1223
+ }
1224
+
1225
+ if (!result.ok) {
1226
+ const lowerError = (result.error || "").toLowerCase();
1227
+ const notFound =
1228
+ result.status === 404 ||
1229
+ lowerError.includes("session not found") ||
1230
+ lowerError.includes("not found") ||
1231
+ lowerError.includes("does not exist");
1232
+ if (notFound) {
1233
+ setHistoryState([]);
1234
+ setTurns([]);
1235
+ setError("Could not load session history. This session could not be found on the backend. Resume is not available.");
1236
+ setIsResumeSessionUsable(false);
1237
+ setIsHistoryLoading(false);
1238
+ return;
1239
+ }
1240
+ setError(
1241
+ `Could not load session history for ${initialConversationId}: ${result.error || "Unable to load conversation history."}` +
1242
+ " Proceeding without preloaded history."
1243
+ );
1244
+ setIsResumeSessionUsable(true);
1245
+ setIsHistoryLoading(false);
1246
+ return;
1247
+ }
1248
+
1249
+ const restoredMessages = (result.data?.messages ?? [])
1250
+ .map((message) => {
1251
+ if (
1252
+ typeof message.role !== "string" ||
1253
+ typeof message.content !== "string"
1254
+ ) {
1255
+ return undefined;
1256
+ }
1257
+
1258
+ return {
1259
+ role: message.role,
1260
+ content: message.content,
1261
+ } as api.AgentChatHistoryItem;
1262
+ })
1263
+ .filter((message): message is api.AgentChatHistoryItem => message !== undefined);
1264
+
1265
+ setHistoryState(restoredMessages);
1266
+ setTurns(mapHistoryToTurns(restoredMessages));
1267
+ setInputHistory(normalizeSessionUserHistory(restoredMessages));
1268
+
1269
+ setIsHistoryLoading(false);
1270
+ };
1271
+
1272
+ void hydrateSessionHistory();
1273
+
1274
+ return () => {
1275
+ isActive = false;
1276
+ };
1277
+ }, [initialConversationId, history]);
1117
1278
 
1118
1279
  const runAutoTurn = async (rawMessage: string) => {
1280
+ if (isHistoryLoading) {
1281
+ return;
1282
+ }
1119
1283
  const trimmed = rawMessage.trim();
1120
1284
  if (!trimmed || isLoading) {
1121
1285
  return;
1122
1286
  }
1287
+ commitInputToHistory(trimmed);
1288
+ if (!isResumeSessionUsable && !allowSessionCreation) {
1289
+ setError("Unable to resume this session because the conversation record is unavailable.");
1290
+ setIsLoading(false);
1291
+ setStatusHint("Ready for next message.");
1292
+ return;
1293
+ }
1123
1294
 
1124
1295
  const nextHistory: api.AgentChatHistoryItem[] = [
1125
1296
  ...historyState,
1126
1297
  { role: "user", content: trimmed },
1127
1298
  ];
1128
1299
  setHistoryState(nextHistory);
1129
- setTurns((current) => [...current, { role: "user", content: trimmed }]);
1130
- setInput("");
1300
+ setInputValue("");
1131
1301
  setError("");
1132
1302
  setIsLoading(true);
1133
1303
  setStatusHint("Thinking...");
1134
1304
 
1135
- const result = await api.agentChat({
1136
- message: trimmed,
1137
- ...(conversationId ? { conversation_id: conversationId } : {}),
1138
- ...(nextHistory.length ? { history: nextHistory } : {}),
1139
- });
1305
+ let activeConversationId = conversationId;
1306
+
1307
+ if (!activeConversationId && !allowSessionCreation) {
1308
+ setError("Unable to resume this session because no valid conversation id is available.");
1309
+ setIsLoading(false);
1310
+ setStatusHint("Ready for next message.");
1311
+ return;
1312
+ }
1313
+
1314
+ if (!activeConversationId) {
1315
+ setStatusHint("Creating conversation...");
1316
+ const createdSession = await api.createAgentSession({
1317
+ first_message: trimmed,
1318
+ title: trimmed.slice(0, 80) || "New agent session",
1319
+ });
1320
+ if (!createdSession.ok) {
1321
+ setError(`Failed to create conversation: ${createdSession.error || "Unknown error."}`);
1322
+ setIsLoading(false);
1323
+ setStatusHint("Ready for next message.");
1324
+ return;
1325
+ }
1326
+ const createdSessionId = createdSession.data?.id;
1327
+ if (!createdSessionId) {
1328
+ setError("Failed to create conversation: Missing conversation id from server response.");
1329
+ setIsLoading(false);
1330
+ setStatusHint("Ready for next message.");
1331
+ return;
1332
+ }
1333
+ activeConversationId = createdSessionId;
1334
+ setConversationId(activeConversationId);
1335
+ persistConversation(activeConversationId);
1336
+ }
1337
+
1338
+ const sendTurn = async (requestConversationId: string | undefined) =>
1339
+ api.agentChat({
1340
+ message: trimmed,
1341
+ ...(requestConversationId ? { conversation_id: requestConversationId } : {}),
1342
+ ...(nextHistory.length ? { history: nextHistory } : {}),
1343
+ });
1344
+
1345
+ let result = await sendTurn(activeConversationId);
1346
+ const lowerError = (result.error || "").toLowerCase();
1347
+ const notFound =
1348
+ result.status === 404 ||
1349
+ lowerError.includes("session not found") ||
1350
+ lowerError.includes("not found") ||
1351
+ lowerError.includes("does not exist");
1140
1352
 
1141
1353
  if (!result.ok) {
1142
- if (
1354
+ if (lowerError.includes("failed to get session") && notFound) {
1355
+ if (!allowSessionCreation) {
1356
+ setError("Unable to resume this session. It is not available on the backend.");
1357
+ setIsResumeSessionUsable(false);
1358
+ } else {
1359
+ setError("Failed to resume session on the backend. Please start a new message to create a new session.");
1360
+ }
1361
+ } else if (
1143
1362
  result.status === 403 &&
1144
- (result.error ?? "").toLowerCase().includes("deep research mode")
1363
+ lowerError.includes("deep research mode")
1145
1364
  ) {
1146
1365
  setError(
1147
1366
  "Research mode requires a Pro subscription.\n" +
@@ -1151,29 +1370,121 @@ function AutoAgentInteractiveSession({
1151
1370
  } else {
1152
1371
  setError(result.error || "Agent request failed.");
1153
1372
  }
1373
+
1374
+ setIsLoading(false);
1375
+ setStatusHint("Ready for next message.");
1376
+ return;
1377
+ }
1378
+
1379
+ const data = result.data;
1380
+ if (!data) {
1381
+ setError("Agent request did not return a usable response.");
1154
1382
  setIsLoading(false);
1155
1383
  setStatusHint("Ready for next message.");
1156
1384
  return;
1157
1385
  }
1158
1386
 
1159
- const answer = result.data.answer?.trim() || "No response content yet.";
1160
- const nextConversationId = result.data.conversation_id || conversationId;
1387
+ const answer = data.answer?.trim() || "No response content yet.";
1388
+ setError("");
1389
+ const persistedConversationId = activeConversationId ?? data.conversation_id;
1390
+ if (persistedConversationId) {
1391
+ setConversationId(persistedConversationId);
1392
+ persistConversation(persistedConversationId);
1393
+ }
1161
1394
 
1162
- setConversationId(nextConversationId);
1163
- setTurns((current) => [...current, { role: "assistant", content: answer }]);
1164
- setHistoryState((current) => [...current, { role: "assistant", content: answer }]);
1395
+ const assistantTurn: api.AgentChatHistoryItem = { role: "assistant", content: answer };
1396
+ if (persistedConversationId) {
1397
+ void api.appendSessionMessages(persistedConversationId, {
1398
+ messages: [
1399
+ { role: "user", content: trimmed },
1400
+ { role: "assistant", content: answer },
1401
+ ],
1402
+ });
1403
+ }
1404
+ setHistoryState((current) => [...current, assistantTurn]);
1165
1405
  setStatusHint("Ready for next message.");
1166
1406
  setIsLoading(false);
1167
1407
  };
1168
1408
 
1409
+ useInput(
1410
+ (character, key) => {
1411
+ if (key.return) {
1412
+ void runAutoTurn(input);
1413
+ return;
1414
+ }
1415
+
1416
+ if (key.backspace || key.delete) {
1417
+ setInputValue(input.slice(0, -1));
1418
+ return;
1419
+ }
1420
+
1421
+ if (key.upArrow) {
1422
+ if (!inputHistory.length) {
1423
+ return;
1424
+ }
1425
+ const nextIndex =
1426
+ inputHistoryIndex === -1 ? inputHistory.length - 1 : Math.max(0, inputHistoryIndex - 1);
1427
+ setInputHistoryIndex(nextIndex);
1428
+ setInput(inputHistory[nextIndex] ?? "");
1429
+ return;
1430
+ }
1431
+
1432
+ if (key.downArrow) {
1433
+ if (inputHistoryIndex === -1) {
1434
+ return;
1435
+ }
1436
+ const nextIndex = inputHistoryIndex + 1;
1437
+ if (nextIndex >= inputHistory.length) {
1438
+ setInputHistoryIndex(-1);
1439
+ setInput("");
1440
+ return;
1441
+ }
1442
+ setInputHistoryIndex(nextIndex);
1443
+ setInput(inputHistory[nextIndex] ?? "");
1444
+ return;
1445
+ }
1446
+
1447
+ if (
1448
+ key.tab ||
1449
+ key.leftArrow ||
1450
+ key.rightArrow ||
1451
+ key.escape ||
1452
+ key.ctrl ||
1453
+ key.meta
1454
+ ) {
1455
+ return;
1456
+ }
1457
+
1458
+ if (character) {
1459
+ setInputValue(input + character);
1460
+ }
1461
+ },
1462
+ { isActive: isRawModeSupported && !isLoading }
1463
+ );
1464
+
1465
+ const persistConversation = (nextConversationId?: string) => {
1466
+ if (nextConversationId) {
1467
+ setLastAgentConversationId(nextConversationId);
1468
+ }
1469
+ };
1470
+
1471
+ useEffect(() => {
1472
+ return () => {
1473
+ persistConversation(conversationId);
1474
+ };
1475
+ }, [conversationId]);
1476
+
1169
1477
  useEffect(() => {
1170
1478
  if (!firstMessage || didSeedFirstMessage.current) {
1171
1479
  return;
1172
1480
  }
1481
+ if (isHistoryLoading) {
1482
+ return;
1483
+ }
1173
1484
 
1174
1485
  didSeedFirstMessage.current = true;
1175
1486
  void runAutoTurn(firstMessage);
1176
- }, [firstMessage]);
1487
+ }, [firstMessage, isHistoryLoading]);
1177
1488
 
1178
1489
  return (
1179
1490
  <Box flexDirection="column">
@@ -1182,6 +1493,7 @@ function AutoAgentInteractiveSession({
1182
1493
  Mode: {mode === "research" ? "research" : "standard"}{" "}
1183
1494
  {conversationId ? `(conversation ${conversationId})` : "(new conversation)"}
1184
1495
  </Text>
1496
+ {isHistoryLoading ? <Loading message="Loading conversation history..." /> : null}
1185
1497
  <Box flexDirection="column" marginTop={1}>
1186
1498
  {turns.map((entry, idx) => (
1187
1499
  <Text key={`agent-turn-${idx}`} dimColor={entry.role === "user"}>
@@ -1194,8 +1506,9 @@ function AutoAgentInteractiveSession({
1194
1506
  {isLoading ? null : (
1195
1507
  <TextInput
1196
1508
  value={input}
1197
- onChange={setInput}
1198
- onSubmit={runAutoTurn}
1509
+ focus={false}
1510
+ onChange={() => {}}
1511
+ onSubmit={() => {}}
1199
1512
  />
1200
1513
  )}
1201
1514
  </Box>
@@ -1209,93 +1522,439 @@ function AutoAgentInteractiveSession({
1209
1522
  );
1210
1523
  }
1211
1524
 
1212
- function AgentInteractivePrompt({
1213
- conversationId,
1214
- history,
1525
+ export function AgentResumeCommand({
1215
1526
  mode,
1216
1527
  }: {
1217
- conversationId?: string;
1218
- history?: api.AgentChatHistoryItem[];
1219
1528
  mode?: "standard" | "research";
1220
1529
  }) {
1221
- const [input, setInput] = useState("");
1222
- const [isReady, setReady] = useState(false);
1223
- const [message, setMessage] = useState("");
1224
1530
  const { isRawModeSupported } = useStdin();
1225
- const shouldExitImmediately = !isRawModeSupported;
1531
+ const [isReady, setReady] = useState(false);
1532
+ const [selectedSession, setSelectedSession] = useState<
1533
+ {
1534
+ id: string;
1535
+ } | undefined
1536
+ >();
1537
+ const [defaultSessionId] = useState(() => getLastAgentConversationId());
1538
+ const [searchQuery, setSearchQuery] = useState("");
1539
+ const [highlightIndex, setHighlightIndex] = useState(0);
1540
+ const [sessions, setSessions] = useState<
1541
+ Array<{
1542
+ id: string;
1543
+ title: string;
1544
+ updated_at: string;
1545
+ is_archived: boolean;
1546
+ }>
1547
+ >([]);
1548
+ const [error, setError] = useState("");
1549
+ const [statusHint, setStatusHint] = useState("Loading sessions...");
1550
+ const [isSessionLoading, setSessionLoading] = useState(false);
1551
+ const [sessionError, setSessionError] = useState("");
1552
+ const [selectedSessionHistory, setSelectedSessionHistory] = useState<
1553
+ api.AgentChatHistoryItem[] | undefined
1554
+ >(undefined);
1226
1555
 
1227
1556
  useEffect(() => {
1228
- if (!shouldExitImmediately) {
1557
+ if (!selectedSession?.id) {
1558
+ setSelectedSessionHistory(undefined);
1559
+ setSessionError("");
1229
1560
  return;
1230
1561
  }
1231
1562
 
1232
- const timeout = setTimeout(() => {
1233
- process.exit(0);
1234
- }, 75);
1563
+ let isActive = true;
1564
+ const loadSessionHistory = async () => {
1565
+ setSessionLoading(true);
1566
+ setSessionError("");
1235
1567
 
1236
- return () => clearTimeout(timeout);
1237
- }, [shouldExitImmediately]);
1568
+ const result = await api.getAgentSession(selectedSession.id);
1569
+ if (!isActive) {
1570
+ return;
1571
+ }
1572
+
1573
+ if (!result.ok) {
1574
+ setSessionError(
1575
+ `Could not load selected session ${selectedSession.id}: ${result.error || "Unknown error."}`
1576
+ );
1577
+ setSelectedSessionHistory(undefined);
1578
+ setSessionLoading(false);
1579
+ return;
1580
+ }
1581
+
1582
+ const restoredMessages = (result.data?.messages ?? [])
1583
+ .map((message) => {
1584
+ if (typeof message?.role !== "string" || typeof message?.content !== "string") {
1585
+ return undefined;
1586
+ }
1587
+ return {
1588
+ role: message.role,
1589
+ content: message.content,
1590
+ } as api.AgentChatHistoryItem;
1591
+ })
1592
+ .filter((message): message is api.AgentChatHistoryItem => message !== undefined);
1593
+
1594
+ setSelectedSessionHistory(restoredMessages);
1595
+ setSessionLoading(false);
1596
+ };
1597
+
1598
+ void loadSessionHistory();
1599
+
1600
+ return () => {
1601
+ isActive = false;
1602
+ setSessionLoading(false);
1603
+ };
1604
+ }, [selectedSession?.id]);
1605
+
1606
+ useEffect(() => {
1607
+ let isActive = true;
1608
+ const loadSessions = async () => {
1609
+ try {
1610
+ const result = await api.listAgentSessions();
1611
+ if (!isActive) {
1612
+ return;
1613
+ }
1614
+ if (!result.ok) {
1615
+ setError(result.error || "Could not load agent sessions.");
1616
+ setStatusHint("");
1617
+ setReady(true);
1618
+ setSessions([]);
1619
+ return;
1620
+ }
1621
+ const remoteSessions = (result.data?.sessions ?? []).map((session) => ({
1622
+ id: session.id,
1623
+ title: session.title || "(untitled)",
1624
+ updated_at: session.updated_at || new Date().toISOString(),
1625
+ is_archived: session.is_archived,
1626
+ }));
1627
+ remoteSessions.sort((a, b) => {
1628
+ const aTime = Date.parse(a.updated_at);
1629
+ const bTime = Date.parse(b.updated_at);
1630
+ if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
1631
+ return 0;
1632
+ }
1633
+ return bTime - aTime;
1634
+ });
1635
+ if (defaultSessionId) {
1636
+ const lastIndex = remoteSessions.findIndex((session) => session.id === defaultSessionId);
1637
+ if (lastIndex >= 0) {
1638
+ setHighlightIndex(lastIndex);
1639
+ }
1640
+ }
1641
+ setSessions(remoteSessions);
1642
+ setStatusHint("");
1643
+ setReady(true);
1644
+ return;
1645
+ } catch (err) {
1646
+ if (!isActive) {
1647
+ return;
1648
+ }
1649
+ const message = err instanceof Error ? err.message : "Could not load agent sessions.";
1650
+ setError(message);
1651
+ setStatusHint("");
1652
+ setSessions([]);
1653
+ setReady(true);
1654
+ }
1655
+ };
1656
+ void loadSessions();
1657
+
1658
+ return () => {
1659
+ isActive = false;
1660
+ };
1661
+ }, []);
1662
+
1663
+ const normalizeSessionLabel = (session: {
1664
+ id: string;
1665
+ title: string;
1666
+ is_archived: boolean;
1667
+ updated_at: string;
1668
+ }) => `${session.title || "(untitled)"} ${session.is_archived ? "[archived] " : ""}(updated ${session.updated_at})`;
1669
+
1670
+ const matchingSessions = sessions.filter((session) => {
1671
+ const searchable = `${session.title} ${session.id}`.toLowerCase();
1672
+ const normalizedQuery = searchQuery.trim().toLowerCase();
1673
+ return normalizedQuery.length === 0 || searchable.includes(normalizedQuery);
1674
+ });
1675
+
1676
+ useInput((_, key) => {
1677
+ if (!matchingSessions.length) return;
1678
+ if (key.upArrow) {
1679
+ setHighlightIndex((idx) => (idx === 0 ? matchingSessions.length - 1 : idx - 1));
1680
+ return;
1681
+ }
1682
+ if (key.downArrow) {
1683
+ setHighlightIndex((idx) => (idx === matchingSessions.length - 1 ? 0 : idx + 1));
1684
+ return;
1685
+ }
1686
+ if (key.return) {
1687
+ const selected = matchingSessions[highlightIndex];
1688
+ if (!selected) {
1689
+ setError("No matching sessions found.");
1690
+ return;
1691
+ }
1692
+ setError("");
1693
+ setSelectedSession({ id: selected.id });
1694
+ }
1695
+ }, { isActive: isRawModeSupported });
1238
1696
 
1239
1697
  if (!isRawModeSupported) {
1240
1698
  return (
1241
1699
  <Box flexDirection="column">
1242
1700
  <ErrorMessage error="Interactive input is not supported in this terminal." />
1243
1701
  <Text>Use interactive mode for this environment:</Text>
1244
- <Text dimColor>{` agent${mode === "research" ? " --mode research" : ""}`}</Text>
1245
1702
  <Text dimColor> agent --help</Text>
1246
1703
  </Box>
1247
1704
  );
1248
1705
  }
1249
1706
 
1707
+ if (error && !sessions.length) {
1708
+ return <ErrorMessage error={error} />;
1709
+ }
1710
+
1250
1711
  if (!isReady) {
1712
+ return <Loading message={statusHint || "Loading sessions..."} />;
1713
+ }
1714
+
1715
+ if (!sessions.length) {
1251
1716
  return (
1252
- <Box flexDirection="column">
1253
- <Text bold>Agent mode selected.</Text>
1254
- <Text>Type your message and press enter to start:</Text>
1255
- <Box>
1256
- <Text color="cyan">&gt; </Text>
1257
- <TextInput
1258
- value={input}
1259
- onChange={setInput}
1260
- onSubmit={(value) => {
1261
- const trimmed = value.trim();
1262
- if (!trimmed) {
1263
- return;
1264
- }
1265
- setMessage(trimmed);
1266
- setReady(true);
1267
- }}
1268
- />
1269
- </Box>
1270
- <Text dimColor>Type Ctrl+C to cancel.</Text>
1271
- </Box>
1717
+ <ErrorMessage
1718
+ error="No agent sessions found. Start a new conversation with `pioneer agent` and try again."
1719
+ />
1272
1720
  );
1273
1721
  }
1274
1722
 
1275
- return (
1276
- <AutoAgentInteractiveSession
1277
- conversationId={conversationId}
1278
- history={history}
1279
- mode={mode === "research" ? "research" : "standard"}
1280
- firstMessage={message}
1281
- />
1282
- );
1283
- }
1723
+ if (selectedSession?.id) {
1724
+ if (isSessionLoading) {
1725
+ return <Loading message={`Loading conversation ${selectedSession.id}...`} />;
1726
+ }
1727
+ if (sessionError) {
1728
+ return <ErrorMessage error={sessionError} />;
1729
+ }
1284
1730
 
1285
- // ─────────────────────────────────────────────────────────────────────────────
1286
- // Interactive Model Create Selector
1287
- // ─────────────────────────────────────────────────────────────────────────────
1731
+ return (
1732
+ <AutoAgentInteractiveSession
1733
+ conversationId={selectedSession.id}
1734
+ mode={mode ?? "standard"}
1735
+ history={selectedSessionHistory}
1736
+ allowSessionCreation={false}
1737
+ />
1738
+ );
1739
+ }
1288
1740
 
1289
- function ModelCreateInteractive({
1290
- name,
1291
- icon,
1292
- repo,
1293
- description,
1294
- example,
1295
- }: {
1296
- name?: string;
1297
- icon?: string;
1298
- repo?: string;
1741
+ return (
1742
+ <Box flexDirection="column">
1743
+ <Text bold>Agent sessions:</Text>
1744
+ <Text>
1745
+ {defaultSessionId ? `Last session: ${defaultSessionId}. ` : ""}
1746
+ Type to filter. Use ↑/↓ to navigate and Enter to select.
1747
+ </Text>
1748
+ <Text dimColor>Search title or session ID.</Text>
1749
+ <Text> </Text>
1750
+ {matchingSessions.length === 0 ? (
1751
+ <Text color="yellow">No sessions match "{searchQuery}".</Text>
1752
+ ) : (
1753
+ matchingSessions.slice(0, 12).map((session, index) => {
1754
+ const isSelected = index === highlightIndex;
1755
+ return (
1756
+ <Text key={session.id} color={isSelected ? "cyan" : undefined}>
1757
+ {`${isSelected ? ">" : " "} ${normalizeSessionLabel(session)} (${session.id})`}
1758
+ </Text>
1759
+ );
1760
+ })
1761
+ )}
1762
+ <Box marginTop={1}>
1763
+ <Text color="cyan">&gt; </Text>
1764
+ <TextInput
1765
+ value={searchQuery}
1766
+ onChange={setSearchQuery}
1767
+ onSubmit={(rawValue) => {
1768
+ const trimmed = rawValue.trim().toLowerCase();
1769
+ if (!trimmed && !matchingSessions.length) {
1770
+ return;
1771
+ }
1772
+ const exactMatch = matchingSessions.find(
1773
+ (session) =>
1774
+ session.id.toLowerCase() === trimmed ||
1775
+ session.title.toLowerCase() === trimmed
1776
+ );
1777
+ const selected = exactMatch ?? matchingSessions[highlightIndex];
1778
+ if (!selected?.id) {
1779
+ setError("No matching session found.");
1780
+ return;
1781
+ }
1782
+ setError("");
1783
+ setSelectedSession({ id: selected.id });
1784
+ }}
1785
+ />
1786
+ </Box>
1787
+ {error && <ErrorMessage error={error} />}
1788
+ </Box>
1789
+ );
1790
+ }
1791
+
1792
+ function AgentInteractivePrompt({
1793
+ conversationId,
1794
+ history,
1795
+ mode,
1796
+ allowSessionCreation = true,
1797
+ }: {
1798
+ conversationId?: string;
1799
+ history?: api.AgentChatHistoryItem[];
1800
+ mode?: "standard" | "research";
1801
+ allowSessionCreation?: boolean;
1802
+ }) {
1803
+ const [input, setInput] = useState("");
1804
+ const [inputHistory, setInputHistory] = useState<string[]>([]);
1805
+ const [inputHistoryIndex, setInputHistoryIndex] = useState(-1);
1806
+ const [isReady, setReady] = useState(false);
1807
+ const [message, setMessage] = useState("");
1808
+ const { isRawModeSupported } = useStdin();
1809
+ const shouldExitImmediately = !isRawModeSupported;
1810
+
1811
+ const commitInputToHistory = (value: string) => {
1812
+ const normalized = value.trim();
1813
+ if (!normalized) {
1814
+ return;
1815
+ }
1816
+ setInputHistory((previous) => {
1817
+ if (previous.length > 0 && previous[previous.length - 1] === normalized) {
1818
+ return previous;
1819
+ }
1820
+ return [...previous, normalized];
1821
+ });
1822
+ };
1823
+
1824
+ const setInputValue = (nextValue: string) => {
1825
+ setInput(nextValue);
1826
+ setInputHistoryIndex(-1);
1827
+ };
1828
+
1829
+ useInput(
1830
+ (character, key) => {
1831
+ if (key.return) {
1832
+ const trimmed = input.trim();
1833
+ if (!trimmed) {
1834
+ return;
1835
+ }
1836
+ commitInputToHistory(trimmed);
1837
+ setMessage(trimmed);
1838
+ setReady(true);
1839
+ return;
1840
+ }
1841
+
1842
+ if (key.backspace || key.delete) {
1843
+ setInputValue(input.slice(0, -1));
1844
+ return;
1845
+ }
1846
+
1847
+ if (key.upArrow) {
1848
+ if (!inputHistory.length) {
1849
+ return;
1850
+ }
1851
+ const nextIndex =
1852
+ inputHistoryIndex === -1 ? inputHistory.length - 1 : Math.max(0, inputHistoryIndex - 1);
1853
+ setInputHistoryIndex(nextIndex);
1854
+ setInput(inputHistory[nextIndex] ?? "");
1855
+ return;
1856
+ }
1857
+
1858
+ if (key.downArrow) {
1859
+ if (inputHistoryIndex === -1) {
1860
+ return;
1861
+ }
1862
+ const nextIndex = inputHistoryIndex + 1;
1863
+ if (nextIndex >= inputHistory.length) {
1864
+ setInputHistoryIndex(-1);
1865
+ setInput("");
1866
+ return;
1867
+ }
1868
+ setInputHistoryIndex(nextIndex);
1869
+ setInput(inputHistory[nextIndex] ?? "");
1870
+ return;
1871
+ }
1872
+
1873
+ if (
1874
+ key.tab ||
1875
+ key.leftArrow ||
1876
+ key.rightArrow ||
1877
+ key.escape ||
1878
+ key.ctrl ||
1879
+ key.meta
1880
+ ) {
1881
+ return;
1882
+ }
1883
+
1884
+ if (character) {
1885
+ setInputValue(input + character);
1886
+ }
1887
+ },
1888
+ { isActive: isRawModeSupported && !isReady }
1889
+ );
1890
+
1891
+ useEffect(() => {
1892
+ if (!shouldExitImmediately) {
1893
+ return;
1894
+ }
1895
+
1896
+ const timeout = setTimeout(() => {
1897
+ process.exit(0);
1898
+ }, 75);
1899
+
1900
+ return () => clearTimeout(timeout);
1901
+ }, [shouldExitImmediately]);
1902
+
1903
+ if (!isRawModeSupported) {
1904
+ return (
1905
+ <Box flexDirection="column">
1906
+ <ErrorMessage error="Interactive input is not supported in this terminal." />
1907
+ <Text>Use interactive mode for this environment:</Text>
1908
+ <Text dimColor>{` agent${mode === "research" ? " --mode research" : ""}`}</Text>
1909
+ <Text dimColor> agent --help</Text>
1910
+ </Box>
1911
+ );
1912
+ }
1913
+
1914
+ if (!isReady) {
1915
+ return (
1916
+ <Box flexDirection="column">
1917
+ <Text bold>Agent mode selected.</Text>
1918
+ <Text>Type your message and press enter to start:</Text>
1919
+ <Box>
1920
+ <Text color="cyan">&gt; </Text>
1921
+ <TextInput
1922
+ value={input}
1923
+ focus={false}
1924
+ onChange={() => {}}
1925
+ onSubmit={() => {}}
1926
+ />
1927
+ </Box>
1928
+ <Text dimColor>Type Ctrl+C to cancel.</Text>
1929
+ </Box>
1930
+ );
1931
+ }
1932
+
1933
+ return (
1934
+ <AutoAgentInteractiveSession
1935
+ conversationId={conversationId}
1936
+ history={history}
1937
+ mode={mode === "research" ? "research" : "standard"}
1938
+ firstMessage={message}
1939
+ allowSessionCreation={allowSessionCreation}
1940
+ />
1941
+ );
1942
+ }
1943
+
1944
+ // ─────────────────────────────────────────────────────────────────────────────
1945
+ // Interactive Model Create Selector
1946
+ // ─────────────────────────────────────────────────────────────────────────────
1947
+
1948
+ function ModelCreateInteractive({
1949
+ name,
1950
+ icon,
1951
+ repo,
1952
+ description,
1953
+ example,
1954
+ }: {
1955
+ name?: string;
1956
+ icon?: string;
1957
+ repo?: string;
1299
1958
  description?: string;
1300
1959
  example?: CreateProjectExample;
1301
1960
  }) {
@@ -1304,6 +1963,7 @@ function ModelCreateInteractive({
1304
1963
  const [loading, setLoading] = useState(true);
1305
1964
  const [error, setError] = useState("");
1306
1965
  const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
1966
+ const { isRawModeSupported } = useStdin();
1307
1967
  const [highlightIndex, setHighlightIndex] = useState(0);
1308
1968
 
1309
1969
  useEffect(() => {
@@ -1352,104 +2012,1311 @@ function ModelCreateInteractive({
1352
2012
  }
1353
2013
  }, [highlightIndex, topMatches.length]);
1354
2014
 
1355
- useInput((_, key) => {
1356
- if (!topMatches.length) return;
1357
- if (key.upArrow) {
1358
- setHighlightIndex((idx) => (idx === 0 ? topMatches.length - 1 : idx - 1));
1359
- return;
1360
- }
1361
- if (key.downArrow) {
1362
- setHighlightIndex((idx) => (idx === topMatches.length - 1 ? 0 : idx + 1));
2015
+ useInput(
2016
+ (_, key) => {
2017
+ if (!topMatches.length) return;
2018
+ if (key.upArrow) {
2019
+ setHighlightIndex((idx) => (idx === 0 ? topMatches.length - 1 : idx - 1));
2020
+ return;
2021
+ }
2022
+ if (key.downArrow) {
2023
+ setHighlightIndex((idx) => (idx === topMatches.length - 1 ? 0 : idx + 1));
2024
+ return;
2025
+ }
2026
+ if (key.return) {
2027
+ const resolvedModel = resolveModelId(query);
2028
+ if (!resolvedModel) {
2029
+ setError("No matching models found. Refine your search and try again.");
2030
+ return;
2031
+ }
2032
+ setSelectedModelId(resolvedModel);
2033
+ }
2034
+ },
2035
+ { isActive: isRawModeSupported }
2036
+ );
2037
+
2038
+ if (error) {
2039
+ return (
2040
+ <Box flexDirection="column">
2041
+ <ErrorMessage error={error} />
2042
+ <Text> </Text>
2043
+ <Text dimColor>Try again with --model explicitly, or check your API connectivity.</Text>
2044
+ </Box>
2045
+ );
2046
+ }
2047
+
2048
+ if (selectedModelId) {
2049
+ return (
2050
+ <ApiCommand
2051
+ action={() =>
2052
+ api.createProject({
2053
+ name: name ?? selectedModelId,
2054
+ ...(icon ? { icon } : {}),
2055
+ ...(repo ? { repo } : {}),
2056
+ ...(description ? { description } : {}),
2057
+ active_model_id: selectedModelId,
2058
+ selected_model_id: selectedModelId,
2059
+ ...(example ? { example } : {}),
2060
+ })
2061
+ }
2062
+ successMessage="Model entry created"
2063
+ />
2064
+ );
2065
+ }
2066
+
2067
+ if (loading) {
2068
+ return <Loading message="Loading supported models..." />;
2069
+ }
2070
+
2071
+ return (
2072
+ <Box flexDirection="column">
2073
+ <Text bold>Choose a base model for this entry</Text>
2074
+ <Text>Type to filter. Use ↑/↓ to navigate and Enter to select.</Text>
2075
+ <Text dimColor>Type any part of model id, name, label, or description.</Text>
2076
+ <Text> </Text>
2077
+ <TextInput
2078
+ value={query}
2079
+ onChange={(value) => setQuery(value)}
2080
+ onSubmit={(value) => {
2081
+ const resolvedModel = resolveModelId(value);
2082
+ if (!resolvedModel) {
2083
+ setError("No matching models found. Refine your search and try again.");
2084
+ return;
2085
+ }
2086
+ setSelectedModelId(resolvedModel);
2087
+ }}
2088
+ placeholder="e.g. qwen/qwen3-8b"
2089
+ />
2090
+ <Text> </Text>
2091
+ {topMatches.length === 0 ? (
2092
+ <Text dimColor>No matching models found.</Text>
2093
+ ) : (
2094
+ <Box flexDirection="column">
2095
+ {topMatches.map((model, index) => {
2096
+ const suffix = [model.label, model.task_type].filter(Boolean).join(" · ");
2097
+ const modelLine = `${model.id}${suffix ? ` (${suffix})` : ""}`;
2098
+ const isHighlighted = index === highlightIndex;
2099
+ return (
2100
+ <Text key={model.id} color={isHighlighted ? "cyan" : undefined} bold={isHighlighted}>
2101
+ {isHighlighted ? "▶ " : " "}
2102
+ {modelLine}
2103
+ </Text>
2104
+ );
2105
+ })}
2106
+ </Box>
2107
+ )}
2108
+ <Text> </Text>
2109
+ <Text dimColor>Press Enter to select the highlighted model.</Text>
2110
+ <Text dimColor> </Text>
2111
+ <Text dimColor>Tip: You can still run {"model endpoints create --model \"<base-model-id>\""} for exact model ids.</Text>
2112
+ </Box>
2113
+ );
2114
+ }
2115
+
2116
+ // ─────────────────────────────────────────────────────────────────────────────
2117
+ // Job ID Resolver
2118
+ // ─────────────────────────────────────────────────────────────────────────────
2119
+
2120
+ function WithResolvedJobId({
2121
+ rawId,
2122
+ render,
2123
+ }: {
2124
+ rawId: string;
2125
+ render: (jobId: string) => React.ReactElement;
2126
+ }) {
2127
+ const initiallyResolved = isUuid(rawId);
2128
+ const [state, setState] = useState<"resolving" | "resolved" | "error">(
2129
+ initiallyResolved ? "resolved" : "resolving"
2130
+ );
2131
+ const [resolvedId, setResolvedId] = useState(initiallyResolved ? rawId : "");
2132
+ const [error, setError] = useState("");
2133
+
2134
+ useEffect(() => {
2135
+ if (initiallyResolved) {
1363
2136
  return;
1364
2137
  }
1365
- if (key.return) {
1366
- const resolvedModel = resolveModelId(query);
1367
- if (!resolvedModel) {
1368
- setError("No matching models found. Refine your search and try again.");
2138
+ let isActive = true;
2139
+ (async () => {
2140
+ const result = await api.listJobs();
2141
+ if (!isActive) return;
2142
+ if (!result.ok) {
2143
+ setError(result.error ?? "Failed to look up training jobs.");
2144
+ setState("error");
1369
2145
  return;
1370
2146
  }
1371
- setSelectedModelId(resolvedModel);
1372
- }
1373
- });
2147
+ const jobs = result.data?.training_jobs ?? [];
2148
+ const norm = rawId.toLowerCase();
2149
+ const matches = jobs.filter((job) =>
2150
+ typeof job.id === "string" && job.id.toLowerCase().startsWith(norm)
2151
+ );
2152
+ if (matches.length === 0) {
2153
+ setError(
2154
+ `No training job matches "${rawId}". Run \`pioneer job list\` to see available IDs.`
2155
+ );
2156
+ setState("error");
2157
+ return;
2158
+ }
2159
+ if (matches.length > 1) {
2160
+ const preview = matches
2161
+ .slice(0, 5)
2162
+ .map((j) => `${j.id} (${j.model_name ?? "—"})`)
2163
+ .join(", ");
2164
+ setError(
2165
+ `Ambiguous job id "${rawId}". Matches: ${preview}${matches.length > 5 ? "…" : ""}. Use a longer prefix or the full UUID.`
2166
+ );
2167
+ setState("error");
2168
+ return;
2169
+ }
2170
+ setResolvedId(matches[0]!.id!);
2171
+ setState("resolved");
2172
+ })();
2173
+
2174
+ return () => {
2175
+ isActive = false;
2176
+ };
2177
+ }, [rawId, initiallyResolved]);
2178
+
2179
+ if (state === "resolving") {
2180
+ return <Loading message={`Resolving job id ${rawId}...`} />;
2181
+ }
2182
+ if (state === "error") {
2183
+ return <ErrorMessage error={error} />;
2184
+ }
2185
+ return render(resolvedId);
2186
+ }
2187
+
2188
+ // ─────────────────────────────────────────────────────────────────────────────
2189
+ // Job List Command (tabular output)
2190
+ // ─────────────────────────────────────────────────────────────────────────────
2191
+
2192
+ function shortJobId(id?: string): string {
2193
+ if (!id) return "—";
2194
+ return id.length > 8 ? id.slice(0, 8) : id;
2195
+ }
2196
+
2197
+ function shortBaseModel(baseModel?: string): string {
2198
+ if (!baseModel) return "—";
2199
+ const parts = baseModel.split("/");
2200
+ return parts[parts.length - 1] || baseModel;
2201
+ }
2202
+
2203
+ function relativeTime(iso?: string): string {
2204
+ if (!iso) return "—";
2205
+ const ts = Date.parse(iso);
2206
+ if (Number.isNaN(ts)) return iso;
2207
+ const diffMs = Date.now() - ts;
2208
+ const seconds = Math.round(diffMs / 1000);
2209
+ if (seconds < 60) return `${Math.max(seconds, 0)}s ago`;
2210
+ const minutes = Math.round(seconds / 60);
2211
+ if (minutes < 60) return `${minutes}m ago`;
2212
+ const hours = Math.round(minutes / 60);
2213
+ if (hours < 24) return `${hours}h ago`;
2214
+ const days = Math.round(hours / 24);
2215
+ if (days < 30) return `${days}d ago`;
2216
+ const months = Math.round(days / 30);
2217
+ if (months < 12) return `${months}mo ago`;
2218
+ const years = Math.round(days / 365);
2219
+ return `${years}y ago`;
2220
+ }
2221
+
2222
+ function statusColor(status?: string): string | undefined {
2223
+ const normalized = (status ?? "").toLowerCase();
2224
+ if (normalized === "complete" || normalized === "completed" || normalized === "succeeded") {
2225
+ return "green";
2226
+ }
2227
+ if (normalized === "failed" || normalized === "error" || normalized === "cancelled" || normalized === "canceled") {
2228
+ return "red";
2229
+ }
2230
+ if (normalized === "running" || normalized === "training" || normalized === "in_progress") {
2231
+ return "cyan";
2232
+ }
2233
+ if (normalized === "pending" || normalized === "queued" || normalized === "starting") {
2234
+ return "yellow";
2235
+ }
2236
+ return undefined;
2237
+ }
2238
+
2239
+ function padCell(value: string, width: number): string {
2240
+ if (value.length === width) return value;
2241
+ if (value.length > width) {
2242
+ if (width <= 1) return value.slice(0, width);
2243
+ return `${value.slice(0, Math.max(width - 1, 1))}…`;
2244
+ }
2245
+ return value + " ".repeat(width - value.length);
2246
+ }
2247
+
2248
+ interface JobRow {
2249
+ id: string;
2250
+ model: string;
2251
+ baseModel: string;
2252
+ task: string;
2253
+ status: string;
2254
+ created: string;
2255
+ [key: string]: string;
2256
+ }
2257
+
2258
+ function jobsToRows(jobs: api.TrainingJob[]): JobRow[] {
2259
+ return jobs.map((job) => ({
2260
+ id: shortJobId(job.id),
2261
+ model: job.model_name?.trim() || "—",
2262
+ baseModel: shortBaseModel(job.base_model),
2263
+ task: job.task_type?.trim() || "—",
2264
+ status: job.status?.trim() || "—",
2265
+ created: relativeTime(job.created_at),
2266
+ }));
2267
+ }
2268
+
2269
+ // ─────────────────────────────────────────────────────────────────────────────
2270
+ // Reusable Table + DetailView primitives
2271
+ // Used by: job list/get, dataset list/get, model base-models, model endpoints get,
2272
+ // model endpoints quality-metrics, model artifacts download.
2273
+ // ─────────────────────────────────────────────────────────────────────────────
2274
+
2275
+ interface TableColumn<TRow> {
2276
+ key: keyof TRow & string;
2277
+ header: string;
2278
+ minWidth: number;
2279
+ maxWidth: number;
2280
+ flexible?: boolean;
2281
+ color?: (row: TRow) => string | undefined;
2282
+ }
2283
+
2284
+ function computeTableWidths<TRow extends Record<string, string>>(
2285
+ columns: TableColumn<TRow>[],
2286
+ rows: TRow[],
2287
+ terminalWidth: number
2288
+ ): number[] {
2289
+ const desired = columns.map((column) => {
2290
+ const headerLength = column.header.length;
2291
+ const dataLength = rows.reduce(
2292
+ (max, row) => Math.max(max, ((row[column.key] as string | undefined) ?? "").length),
2293
+ 0
2294
+ );
2295
+ const want = Math.max(headerLength, dataLength);
2296
+ return Math.max(column.minWidth, Math.min(column.maxWidth, want));
2297
+ });
2298
+
2299
+ const separators = (columns.length - 1) * 2;
2300
+ const available = Math.max(40, terminalWidth - 1);
2301
+ const total = desired.reduce((sum, w) => sum + w, 0) + separators;
2302
+
2303
+ if (total <= available) return desired;
2304
+
2305
+ let overflow = total - available;
2306
+ const flexIndices = columns
2307
+ .map((column, idx) => (column.flexible ? idx : -1))
2308
+ .filter((idx) => idx >= 0);
2309
+
2310
+ while (overflow > 0) {
2311
+ let trimmedThisPass = false;
2312
+ for (const idx of flexIndices) {
2313
+ if (overflow <= 0) break;
2314
+ if (desired[idx]! > columns[idx]!.minWidth) {
2315
+ desired[idx] = desired[idx]! - 1;
2316
+ overflow -= 1;
2317
+ trimmedThisPass = true;
2318
+ }
2319
+ }
2320
+ if (!trimmedThisPass) break;
2321
+ }
2322
+
2323
+ return desired;
2324
+ }
2325
+
2326
+ function useTerminalWidth(): number {
2327
+ const { stdout } = useStdout();
2328
+ const [width, setWidth] = useState(() => {
2329
+ const cols = stdout?.columns;
2330
+ if (typeof cols === "number" && cols > 0) return cols;
2331
+ const env = parseInt(process.env.COLUMNS ?? "", 10);
2332
+ return Number.isFinite(env) && env > 0 ? env : 80;
2333
+ });
2334
+ useEffect(() => {
2335
+ if (!stdout) return;
2336
+ const handler = () => {
2337
+ if (typeof stdout.columns === "number" && stdout.columns > 0) {
2338
+ setWidth(stdout.columns);
2339
+ }
2340
+ };
2341
+ stdout.on("resize", handler);
2342
+ return () => {
2343
+ stdout.off("resize", handler);
2344
+ };
2345
+ }, [stdout]);
2346
+ return width;
2347
+ }
2348
+
2349
+ function DataTable<TRow extends Record<string, string>>({
2350
+ columns,
2351
+ rows,
2352
+ footer,
2353
+ }: {
2354
+ columns: TableColumn<TRow>[];
2355
+ rows: TRow[];
2356
+ footer?: string;
2357
+ }) {
2358
+ const terminalWidth = useTerminalWidth();
2359
+ const widths = computeTableWidths(columns, rows, terminalWidth);
2360
+
2361
+ const headerCells = columns.map((column, idx) =>
2362
+ padCell(column.header, widths[idx]!)
2363
+ );
2364
+
2365
+ return (
2366
+ <Box flexDirection="column">
2367
+ <Text bold dimColor>{headerCells.join(" ")}</Text>
2368
+ {rows.map((row, rowIdx) => {
2369
+ const cells = columns.map((column, idx) => {
2370
+ const raw = (row[column.key] as string | undefined) ?? "";
2371
+ return padCell(String(raw), widths[idx]!);
2372
+ });
2373
+ return (
2374
+ <Text key={rowIdx}>
2375
+ {columns.map((column, idx) => {
2376
+ const cell = cells[idx]!;
2377
+ const color = column.color?.(row);
2378
+ return (
2379
+ <React.Fragment key={String(column.key)}>
2380
+ {idx > 0 ? " " : ""}
2381
+ {color ? <Text color={color}>{cell}</Text> : cell}
2382
+ </React.Fragment>
2383
+ );
2384
+ })}
2385
+ </Text>
2386
+ );
2387
+ })}
2388
+ {footer ? (
2389
+ <Box marginTop={1}>
2390
+ <Text dimColor>{footer}</Text>
2391
+ </Box>
2392
+ ) : null}
2393
+ </Box>
2394
+ );
2395
+ }
2396
+
2397
+ interface DetailRow {
2398
+ label: string;
2399
+ value: string;
2400
+ valueColor?: string;
2401
+ }
2402
+
2403
+ function DetailView({
2404
+ rows,
2405
+ footer,
2406
+ }: {
2407
+ rows: DetailRow[];
2408
+ footer?: string;
2409
+ }) {
2410
+ if (rows.length === 0) {
2411
+ return <Text dimColor>No data.</Text>;
2412
+ }
2413
+ // Render single-record details through the same DataTable primitive used for
2414
+ // list views, so `<thing> get` and `<thing> list` share the exact same
2415
+ // visual style (uppercase headers, column padding, ellipsizing).
2416
+ const tableRows = rows.map((row) => ({
2417
+ field: row.label.toUpperCase(),
2418
+ value: row.value,
2419
+ __valueColor: row.valueColor ?? "",
2420
+ }));
2421
+ const columns: TableColumn<(typeof tableRows)[number]>[] = [
2422
+ { key: "field", header: "FIELD", minWidth: 6, maxWidth: 24 },
2423
+ {
2424
+ key: "value",
2425
+ header: "VALUE",
2426
+ minWidth: 10,
2427
+ maxWidth: 80,
2428
+ flexible: true,
2429
+ color: (row) => row.__valueColor || undefined,
2430
+ },
2431
+ ];
2432
+ return <DataTable columns={columns} rows={tableRows} footer={footer} />;
2433
+ }
2434
+
2435
+ const JOB_COLUMNS: TableColumn<JobRow>[] = [
2436
+ { key: "id", header: "ID", minWidth: 8, maxWidth: 8 },
2437
+ { key: "model", header: "MODEL", minWidth: 8, maxWidth: 32, flexible: true },
2438
+ { key: "baseModel", header: "BASE MODEL", minWidth: 10, maxWidth: 30, flexible: true },
2439
+ { key: "task", header: "TASK", minWidth: 4, maxWidth: 14, flexible: true },
2440
+ { key: "status", header: "STATUS", minWidth: 6, maxWidth: 10, color: (row) => statusColor(row.status) },
2441
+ { key: "created", header: "CREATED", minWidth: 7, maxWidth: 10 },
2442
+ ];
2443
+
2444
+ export function JobListCommand() {
2445
+ const { exit } = useApp();
2446
+ const [state, setState] = useState<"loading" | "done" | "error">("loading");
2447
+ const [jobs, setJobs] = useState<api.TrainingJob[]>([]);
2448
+ const [error, setError] = useState("");
2449
+
2450
+ useEffect(() => {
2451
+ (async () => {
2452
+ const result = await api.listJobs();
2453
+ if (!result.ok) {
2454
+ setError(result.error ?? "Failed to load training jobs.");
2455
+ setState("error");
2456
+ } else {
2457
+ setJobs(result.data?.training_jobs ?? []);
2458
+ setState("done");
2459
+ }
2460
+ setTimeout(() => exit(), 500);
2461
+ })();
2462
+ }, [exit]);
2463
+
2464
+ if (state === "loading") return <Loading message="Loading training jobs..." />;
2465
+ if (state === "error") return <ErrorMessage error={error} />;
2466
+ if (jobs.length === 0) {
2467
+ return (
2468
+ <Box flexDirection="column">
2469
+ <Text>No training jobs found.</Text>
2470
+ <Text dimColor>Create one with `pioneer agent` — it will guide you through picking a base model and datasets.</Text>
2471
+ </Box>
2472
+ );
2473
+ }
2474
+
2475
+ return (
2476
+ <DataTable
2477
+ columns={JOB_COLUMNS}
2478
+ rows={jobsToRows(jobs)}
2479
+ footer={`${jobs.length} job${jobs.length === 1 ? "" : "s"} · the short ID prefix above is enough for \`pioneer job get/logs/delete\` · use \`--json\` for raw JSON`}
2480
+ />
2481
+ );
2482
+ }
2483
+
2484
+ // ─────────────────────────────────────────────────────────────────────────────
2485
+ // Job Get Command (single-job key/value layout)
2486
+ // ─────────────────────────────────────────────────────────────────────────────
2487
+
2488
+ function formatTimestamp(iso?: string): string {
2489
+ if (!iso) return "—";
2490
+ const ts = Date.parse(iso);
2491
+ if (Number.isNaN(ts)) return iso;
2492
+ const date = new Date(ts);
2493
+ const pad = (n: number) => n.toString().padStart(2, "0");
2494
+ const yyyy = date.getUTCFullYear();
2495
+ const mm = pad(date.getUTCMonth() + 1);
2496
+ const dd = pad(date.getUTCDate());
2497
+ const hh = pad(date.getUTCHours());
2498
+ const mi = pad(date.getUTCMinutes());
2499
+ const ss = pad(date.getUTCSeconds());
2500
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss} UTC (${relativeTime(iso)})`;
2501
+ }
2502
+
2503
+ function formatDuration(startIso?: string, endIso?: string): string {
2504
+ if (!startIso || !endIso) return "—";
2505
+ const start = Date.parse(startIso);
2506
+ const end = Date.parse(endIso);
2507
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return "—";
2508
+ const seconds = Math.floor((end - start) / 1000);
2509
+ const days = Math.floor(seconds / 86400);
2510
+ const hours = Math.floor((seconds % 86400) / 3600);
2511
+ const minutes = Math.floor((seconds % 3600) / 60);
2512
+ const remSeconds = seconds % 60;
2513
+ const parts: string[] = [];
2514
+ if (days) parts.push(`${days}d`);
2515
+ if (hours) parts.push(`${hours}h`);
2516
+ if (minutes) parts.push(`${minutes}m`);
2517
+ if (!parts.length || remSeconds) parts.push(`${remSeconds}s`);
2518
+ return parts.join(" ");
2519
+ }
2520
+
2521
+ function formatDatasetList(
2522
+ datasets?: Array<{ name?: string; version?: string | number }>
2523
+ ): string {
2524
+ if (!datasets || datasets.length === 0) return "—";
2525
+ return datasets
2526
+ .map((ds) => {
2527
+ const name = ds?.name ?? "(unnamed)";
2528
+ const version = ds?.version != null && ds.version !== "" ? ` (v${ds.version})` : "";
2529
+ return `${name}${version}`;
2530
+ })
2531
+ .join(", ");
2532
+ }
2533
+
2534
+ function formatLabels(labels?: string[]): string {
2535
+ if (!labels || labels.length === 0) return "—";
2536
+ return labels.join(", ");
2537
+ }
2538
+
2539
+ function formatBoolean(value: unknown): string {
2540
+ if (value === true) return "yes";
2541
+ if (value === false) return "no";
2542
+ return "—";
2543
+ }
2544
+
2545
+ function formatNumber(value: unknown): string {
2546
+ if (typeof value !== "number" || Number.isNaN(value)) return "—";
2547
+ return Number.isInteger(value) ? String(value) : value.toString();
2548
+ }
2549
+
2550
+ function formatPercent(value: unknown): string {
2551
+ if (typeof value !== "number" || Number.isNaN(value)) return "—";
2552
+ const pct = value > 1 ? value : value * 100;
2553
+ return `${Number.isInteger(pct) ? pct : pct.toFixed(1)}%`;
2554
+ }
2555
+
2556
+ function formatNonEmpty(value: unknown): string {
2557
+ if (typeof value === "string") {
2558
+ const trimmed = value.trim();
2559
+ return trimmed.length ? trimmed : "—";
2560
+ }
2561
+ return "—";
2562
+ }
2563
+
2564
+ function jobToDetailRows(job: api.TrainingJob): DetailRow[] {
2565
+ const record = job as unknown as Record<string, unknown>;
2566
+ const provider =
2567
+ typeof record.provider_name === "string" ? record.provider_name : undefined;
2568
+ const progressPercent = typeof record.progress_percent === "number" ? record.progress_percent : undefined;
2569
+ const currentEpoch = typeof record.current_epoch === "number" ? record.current_epoch : undefined;
2570
+
2571
+ const rows: DetailRow[] = [
2572
+ { label: "ID", value: formatNonEmpty(job.id) },
2573
+ { label: "Model", value: formatNonEmpty(job.model_name) },
2574
+ { label: "Base model", value: formatNonEmpty(job.base_model) },
2575
+ { label: "Task", value: formatNonEmpty(job.task_type) },
2576
+ { label: "Status", value: formatNonEmpty(job.status), valueColor: statusColor(job.status) },
2577
+ { label: "Provider", value: formatNonEmpty(provider) },
2578
+ { label: "Datasets", value: formatDatasetList(job.datasets) },
2579
+ { label: "Labels", value: formatLabels(job.labels) },
2580
+ { label: "Epochs", value: formatNumber(job.nr_epochs) },
2581
+ { label: "Batch size", value: formatNumber(job.batch_size) },
2582
+ { label: "Learning rate", value: formatNumber(job.learning_rate) },
2583
+ { label: "Validation split", value: formatPercent(job.validation_data_percentage) },
2584
+ { label: "Auto-selected", value: formatBoolean(job.model_auto_selected) },
2585
+ { label: "Version", value: formatNonEmpty(job.version_number) },
2586
+ { label: "Created", value: formatTimestamp(job.created_at) },
2587
+ { label: "Started", value: formatTimestamp(job.started_at) },
2588
+ { label: "Completed", value: formatTimestamp(job.completed_at) },
2589
+ { label: "Duration", value: formatDuration(job.started_at, job.completed_at) },
2590
+ { label: "Updated", value: formatTimestamp(job.updated_at) },
2591
+ ];
2592
+
2593
+ if (progressPercent !== undefined) {
2594
+ rows.splice(5, 0, {
2595
+ label: "Progress",
2596
+ value: `${progressPercent.toFixed(1)}%${currentEpoch != null ? ` (epoch ${currentEpoch})` : ""}`,
2597
+ });
2598
+ }
2599
+ if (job.error_message) {
2600
+ rows.push({
2601
+ label: "Error",
2602
+ value: job.error_message,
2603
+ valueColor: "red",
2604
+ });
2605
+ }
2606
+
2607
+ return rows;
2608
+ }
2609
+
2610
+ function JobGetCommand({ jobId }: { jobId: string }) {
2611
+ const { exit } = useApp();
2612
+ const [state, setState] = useState<"loading" | "done" | "error">("loading");
2613
+ const [job, setJob] = useState<api.TrainingJob | null>(null);
2614
+ const [error, setError] = useState("");
2615
+
2616
+ useEffect(() => {
2617
+ let isActive = true;
2618
+ (async () => {
2619
+ const result = await api.getJob(jobId);
2620
+ if (!isActive) return;
2621
+ if (!result.ok) {
2622
+ setError(result.error ?? "Failed to load training job.");
2623
+ setState("error");
2624
+ } else {
2625
+ setJob((result.data as api.TrainingJob | undefined) ?? null);
2626
+ setState("done");
2627
+ }
2628
+ setTimeout(() => exit(), 500);
2629
+ })();
2630
+ return () => {
2631
+ isActive = false;
2632
+ };
2633
+ }, [jobId, exit]);
2634
+
2635
+ if (state === "loading") {
2636
+ return <Loading message={`Loading training job ${jobId}...`} />;
2637
+ }
2638
+ if (state === "error") {
2639
+ return <ErrorMessage error={error} />;
2640
+ }
2641
+ if (!job) {
2642
+ return <ErrorMessage error="Training job response was empty." />;
2643
+ }
2644
+
2645
+ return (
2646
+ <DetailView
2647
+ rows={jobToDetailRows(job)}
2648
+ footer={`Use \`pioneer job get ${jobId} --json\` for the raw JSON payload.`}
2649
+ />
2650
+ );
2651
+ }
2652
+
2653
+ // ─────────────────────────────────────────────────────────────────────────────
2654
+ // Base Models List Command (tabular)
2655
+ // ─────────────────────────────────────────────────────────────────────────────
2656
+
2657
+ interface BaseModelRow {
2658
+ id: string;
2659
+ name: string;
2660
+ type: string;
2661
+ context: string;
2662
+ inference: string;
2663
+ training: string;
2664
+ [key: string]: string;
2665
+ }
2666
+
2667
+ const BASE_MODEL_COLUMNS: TableColumn<BaseModelRow>[] = [
2668
+ { key: "id", header: "ID", minWidth: 12, maxWidth: 36, flexible: true },
2669
+ { key: "name", header: "NAME", minWidth: 8, maxWidth: 30, flexible: true },
2670
+ { key: "type", header: "TYPE", minWidth: 4, maxWidth: 8 },
2671
+ { key: "context", header: "CONTEXT", minWidth: 7, maxWidth: 8 },
2672
+ { key: "inference", header: "INFERENCE", minWidth: 9, maxWidth: 9, color: (row) => (row.inference === "yes" ? "green" : "red") },
2673
+ { key: "training", header: "TRAINING", minWidth: 8, maxWidth: 8, color: (row) => (row.training === "yes" ? "green" : "red") },
2674
+ ];
2675
+
2676
+ function formatContextWindow(n?: number): string {
2677
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) return "—";
2678
+ if (n >= 1_000_000) {
2679
+ const v = n / 1_000_000;
2680
+ return `${Number.isInteger(v) ? v : v.toFixed(1)}M`;
2681
+ }
2682
+ if (n >= 1000) {
2683
+ const v = n / 1000;
2684
+ return `${Number.isInteger(v) ? v : v.toFixed(0)}K`;
2685
+ }
2686
+ return String(n);
2687
+ }
2688
+
2689
+ function BaseModelsListCommand() {
2690
+ const { exit } = useApp();
2691
+ const [state, setState] = useState<"loading" | "done" | "error">("loading");
2692
+ const [models, setModels] = useState<api.BaseModelInfo[]>([]);
2693
+ const [error, setError] = useState("");
2694
+
2695
+ useEffect(() => {
2696
+ (async () => {
2697
+ const result = await api.listBaseModels();
2698
+ if (!result.ok) {
2699
+ setError(result.error ?? "Failed to load base models.");
2700
+ setState("error");
2701
+ } else {
2702
+ setModels(normalizeBaseModels(result.data));
2703
+ setState("done");
2704
+ }
2705
+ setTimeout(() => exit(), 500);
2706
+ })();
2707
+ }, [exit]);
2708
+
2709
+ if (state === "loading") return <Loading message="Loading base models..." />;
2710
+ if (state === "error") return <ErrorMessage error={error} />;
2711
+ if (models.length === 0) {
2712
+ return <Text>No base models available.</Text>;
2713
+ }
2714
+
2715
+ const rows: BaseModelRow[] = models.map((m) => {
2716
+ const inference = m.supports_inference ?? m.supports_on_demand_inference;
2717
+ return {
2718
+ id: m.id || "—",
2719
+ name: (m.label || m.name || m.id || "—").trim() || "—",
2720
+ type: m.task_type || "—",
2721
+ context: formatContextWindow(m.context_window),
2722
+ inference: inference === undefined ? "—" : inference ? "yes" : "no",
2723
+ training: m.supports_training === undefined ? "—" : m.supports_training ? "yes" : "no",
2724
+ };
2725
+ });
2726
+
2727
+ return (
2728
+ <DataTable
2729
+ columns={BASE_MODEL_COLUMNS}
2730
+ rows={rows}
2731
+ footer={`${models.length} base model${models.length === 1 ? "" : "s"} · use \`--json\` for raw JSON`}
2732
+ />
2733
+ );
2734
+ }
2735
+
2736
+ // ─────────────────────────────────────────────────────────────────────────────
2737
+ // Model Endpoint Get Command (single-resource key/value layout, with dataset count)
2738
+ // ─────────────────────────────────────────────────────────────────────────────
2739
+
2740
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2741
+
2742
+ function ModelEndpointGetCommand({ modelId }: { modelId: string }) {
2743
+ const { exit } = useApp();
2744
+ const [state, setState] = useState<"loading" | "done" | "error">("loading");
2745
+ const [project, setProject] = useState<api.ProjectResponse | null>(null);
2746
+ const [datasetCount, setDatasetCount] = useState<api.ProjectDatasetCountResponse | null>(null);
2747
+ const [activeJob, setActiveJob] = useState<api.TrainingJob | null>(null);
2748
+ const [error, setError] = useState("");
2749
+
2750
+ useEffect(() => {
2751
+ let active = true;
2752
+ (async () => {
2753
+ // Sequential to dodge Bun concurrent-fetch keep-alive flakiness.
2754
+ const projectResult = await api.getProject(modelId);
2755
+ if (!active) return;
2756
+ if (!projectResult.ok) {
2757
+ setError(projectResult.error ?? "Failed to load model endpoint.");
2758
+ setState("error");
2759
+ setTimeout(() => exit(), 500);
2760
+ return;
2761
+ }
2762
+ setProject(projectResult.data ?? null);
2763
+
2764
+ const countResult = await api.getProjectDatasetCount(modelId);
2765
+ if (!active) return;
2766
+ if (countResult.ok) setDatasetCount(countResult.data ?? null);
2767
+
2768
+ // If the selected model id is a UUID, it's a training job (a deployed
2769
+ // fine-tuned checkpoint). Hydrate it so we can show a friendly name
2770
+ // instead of just the UUID.
2771
+ const selected = projectResult.data?.selected_model_id ?? "";
2772
+ if (UUID_REGEX.test(selected)) {
2773
+ const jobResult = await api.getJob(selected);
2774
+ if (!active) return;
2775
+ if (jobResult.ok) setActiveJob(jobResult.data ?? null);
2776
+ }
2777
+
2778
+ setState("done");
2779
+ setTimeout(() => exit(), 500);
2780
+ })();
2781
+ return () => {
2782
+ active = false;
2783
+ };
2784
+ }, [modelId, exit]);
2785
+
2786
+ if (state === "loading") return <Loading message={`Loading model endpoint ${modelId}...`} />;
2787
+ if (state === "error") return <ErrorMessage error={error} />;
2788
+ if (!project) return <ErrorMessage error="Model endpoint response was empty." />;
2789
+
2790
+ const selected = project.selected_model_id ?? "";
2791
+ const selectedDisplay = (() => {
2792
+ if (!selected) return "—";
2793
+ if (activeJob) {
2794
+ const parts: string[] = [];
2795
+ if (activeJob.model_name) parts.push(activeJob.model_name);
2796
+ if (activeJob.base_model) parts.push(`base: ${activeJob.base_model}`);
2797
+ const tail = parts.length ? ` (${parts.join(", ")})` : "";
2798
+ return `${shortJobId(selected)}${tail}`;
2799
+ }
2800
+ // Stock model id (or UUID we couldn't resolve) — render as-is.
2801
+ return UUID_REGEX.test(selected) ? shortJobId(selected) : selected;
2802
+ })();
2803
+
2804
+ const rows: DetailRow[] = [
2805
+ { label: "ID", value: formatNonEmpty(project.id) },
2806
+ { label: "Name", value: formatNonEmpty(project.name) },
2807
+ { label: "Description", value: formatNonEmpty(project.description) },
2808
+ { label: "Icon", value: formatNonEmpty(project.icon) },
2809
+ { label: "Repo", value: formatNonEmpty(project.repo) },
2810
+ { label: "Selected model", value: selectedDisplay },
2811
+ { label: "Created", value: formatTimestamp(project.created_at) },
2812
+ { label: "Updated", value: formatTimestamp(project.updated_at) },
2813
+ ];
2814
+ if (datasetCount) {
2815
+ rows.push(
2816
+ { label: "Datasets", value: formatNumber(datasetCount.dataset_count) },
2817
+ {
2818
+ label: "Can delete",
2819
+ value: formatBoolean(datasetCount.can_delete),
2820
+ valueColor: datasetCount.can_delete ? "green" : "yellow",
2821
+ }
2822
+ );
2823
+ }
2824
+
2825
+ return (
2826
+ <DetailView
2827
+ rows={rows}
2828
+ footer={`Use \`pioneer model endpoints get ${modelId} --json\` for the raw JSON payload.`}
2829
+ />
2830
+ );
2831
+ }
2832
+
2833
+ // ─────────────────────────────────────────────────────────────────────────────
2834
+ // Model Endpoint Quality Metrics Command (compact key/value layout)
2835
+ // ─────────────────────────────────────────────────────────────────────────────
2836
+
2837
+ function ModelQualityMetricsCommand({ modelId }: { modelId: string }) {
2838
+ const { exit } = useApp();
2839
+ const [state, setState] = useState<"loading" | "done" | "error">("loading");
2840
+ const [data, setData] = useState<api.QualityMetricsResponse | null>(null);
2841
+ const [error, setError] = useState("");
2842
+
2843
+ useEffect(() => {
2844
+ let active = true;
2845
+ (async () => {
2846
+ const result = await api.getProjectQualityMetrics(modelId);
2847
+ if (!active) return;
2848
+ if (!result.ok) {
2849
+ setError(result.error ?? "Failed to load quality metrics.");
2850
+ setState("error");
2851
+ } else {
2852
+ setData(result.data ?? null);
2853
+ setState("done");
2854
+ }
2855
+ setTimeout(() => exit(), 500);
2856
+ })();
2857
+ return () => {
2858
+ active = false;
2859
+ };
2860
+ }, [modelId, exit]);
2861
+
2862
+ if (state === "loading") return <Loading message={`Loading quality metrics for ${modelId}...`} />;
2863
+ if (state === "error") return <ErrorMessage error={error} />;
2864
+ if (!data) return <ErrorMessage error="Quality metrics response was empty." />;
2865
+
2866
+ const rows: DetailRow[] = [
2867
+ { label: "Model ID", value: formatNonEmpty(data.project_id) },
2868
+ { label: "Total judged", value: formatNumber(data.total_judged) },
2869
+ { label: "Pass count", value: formatNumber(data.pass_count), valueColor: "green" },
2870
+ { label: "Fail count", value: formatNumber(data.fail_count), valueColor: data.fail_count > 0 ? "red" : undefined },
2871
+ { label: "Uncertain count", value: formatNumber(data.uncertain_count), valueColor: data.uncertain_count > 0 ? "yellow" : undefined },
2872
+ { label: "Pass rate", value: formatPercent(data.pass_rate), valueColor: "green" },
2873
+ { label: "Fail rate", value: formatPercent(data.fail_rate), valueColor: data.fail_rate > 0 ? "red" : undefined },
2874
+ ];
2875
+
2876
+ return (
2877
+ <DetailView
2878
+ rows={rows}
2879
+ footer={`Use \`pioneer model endpoints quality-metrics ${modelId} --json\` for the raw JSON payload.`}
2880
+ />
2881
+ );
2882
+ }
2883
+
2884
+ // ─────────────────────────────────────────────────────────────────────────────
2885
+ // Deploy Job Picker (interactive picker for `model endpoints deploy` with no --job)
2886
+ // ─────────────────────────────────────────────────────────────────────────────
2887
+
2888
+ function isJobDeployable(status?: string): boolean {
2889
+ const s = (status ?? "").trim().toLowerCase();
2890
+ return s === "complete" || s === "completed" || s === "succeeded" || s === "deployed";
2891
+ }
2892
+
2893
+ /**
2894
+ * Normalize a model identifier to a "family" key for matching.
2895
+ * Strips the org/namespace prefix, lowercases, and drops common
2896
+ * variant suffixes (-instruct, -chat, -base, -it, -hf) so that, e.g.,
2897
+ * "qwen/Qwen3-8B-Instruct" → "qwen3-8b"
2898
+ * "Qwen/Qwen3-8B" → "qwen3-8b"
2899
+ * "meta-llama/Llama-3.1-8B-Instruct" → "llama-3.1-8b"
2900
+ * are treated as the same family.
2901
+ */
2902
+ function modelFamilyKey(modelId?: string | null): string {
2903
+ if (!modelId) return "";
2904
+ const last = modelId.toLowerCase().split("/").pop() ?? "";
2905
+ return last
2906
+ .replace(/-instruct$/, "")
2907
+ .replace(/-chat$/, "")
2908
+ .replace(/-base$/, "")
2909
+ .replace(/-it$/, "")
2910
+ .replace(/-hf$/, "")
2911
+ .trim();
2912
+ }
2913
+
2914
+ function jobMatchesEndpoint(job: api.TrainingJob, selectedModelId?: string): boolean {
2915
+ if (!selectedModelId) return true;
2916
+ const want = modelFamilyKey(selectedModelId);
2917
+ const have = modelFamilyKey(job.base_model);
2918
+ if (!want || !have) return true;
2919
+ return want === have || want.startsWith(have) || have.startsWith(want);
2920
+ }
2921
+
2922
+ export function DeployJobPickerCommand({
2923
+ modelId,
2924
+ reason,
2925
+ showAll,
2926
+ }: {
2927
+ modelId: string;
2928
+ reason?: string;
2929
+ showAll?: boolean;
2930
+ }) {
2931
+ const { exit } = useApp();
2932
+ const { isRawModeSupported } = useStdin();
2933
+ const [phase, setPhase] = useState<"loading" | "picking" | "deploying" | "done" | "error">("loading");
2934
+ const [jobs, setJobs] = useState<api.TrainingJob[]>([]);
2935
+ const [endpointModel, setEndpointModel] = useState<string | undefined>(undefined);
2936
+ const [filterApplied, setFilterApplied] = useState<boolean>(false);
2937
+ const [filterSource, setFilterSource] = useState<"project" | "family" | "none">("none");
2938
+ const [error, setError] = useState("");
2939
+ const [searchQuery, setSearchQuery] = useState("");
2940
+ const [highlightIndex, setHighlightIndex] = useState(0);
2941
+ const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
2942
+ const [deployResult, setDeployResult] = useState<unknown>(null);
2943
+
2944
+ useEffect(() => {
2945
+ let active = true;
2946
+ (async () => {
2947
+ // Run sequentially to avoid Bun concurrent-fetch keep-alive flakiness
2948
+ // ("socket connection was closed unexpectedly"). Retry once on transient
2949
+ // network errors.
2950
+ const isTransient = (err?: string) =>
2951
+ !!err && /socket|ECONNRESET|fetch failed|network|EAI_AGAIN|timeout/i.test(err);
2952
+
2953
+ const fetchWithRetry = async <T,>(fn: () => Promise<api.ApiResult<T>>): Promise<api.ApiResult<T>> => {
2954
+ let r = await fn();
2955
+ if (!r.ok && (r.status === 0 || isTransient(r.error))) {
2956
+ await new Promise((res) => setTimeout(res, 250));
2957
+ r = await fn();
2958
+ }
2959
+ return r;
2960
+ };
2961
+
2962
+ const sortByRecent = (list: api.TrainingJob[]) =>
2963
+ list.slice().sort((a, b) => {
2964
+ const at = Date.parse(a.completed_at ?? a.updated_at ?? a.created_at ?? "");
2965
+ const bt = Date.parse(b.completed_at ?? b.updated_at ?? b.created_at ?? "");
2966
+ if (Number.isNaN(at) || Number.isNaN(bt)) return 0;
2967
+ return bt - at;
2968
+ });
2969
+
2970
+ // 1. Resolve the endpoint. A hard 404/403 means the endpoint doesn't exist
2971
+ // on this backend — fail fast rather than open a picker for a target
2972
+ // that any subsequent deploy call will reject. Other errors (5xx,
2973
+ // transient network) degrade gracefully: we just can't apply the
2974
+ // family filter.
2975
+ const projectResult = await fetchWithRetry(() => api.getProject(modelId));
2976
+ if (!active) return;
2977
+ if (!projectResult.ok && (projectResult.status === 404 || projectResult.status === 403)) {
2978
+ const reason =
2979
+ projectResult.status === 403
2980
+ ? "You do not have access to this endpoint."
2981
+ : "This endpoint does not exist on the current backend.";
2982
+ setError(
2983
+ `${reason} (model id: ${modelId})\nList valid endpoints with: pioneer model endpoints list`
2984
+ );
2985
+ setPhase("error");
2986
+ setTimeout(() => exit(), 500);
2987
+ return;
2988
+ }
2989
+ const selected = projectResult.ok ? projectResult.data?.selected_model_id : undefined;
2990
+ setEndpointModel(selected ?? undefined);
2991
+
2992
+ // 2. Try server-side filter by project_id. Today most jobs have project_id=null
2993
+ // so this often returns []; we fall back to all-jobs + family match below.
2994
+ let list: api.TrainingJob[] = [];
2995
+ let didFilter = false;
2996
+ let filterSource: "project" | "family" | "none" = "none";
2997
+
2998
+ if (!showAll) {
2999
+ const scoped = await fetchWithRetry(() => api.listJobs({ project_id: modelId }));
3000
+ if (!active) return;
3001
+ if (scoped.ok) {
3002
+ const scopedDeployable = sortByRecent(
3003
+ (scoped.data?.training_jobs ?? []).filter((j) => isJobDeployable(j.status))
3004
+ );
3005
+ if (scopedDeployable.length > 0) {
3006
+ list = scopedDeployable;
3007
+ didFilter = true;
3008
+ filterSource = "project";
3009
+ }
3010
+ }
3011
+ }
3012
+
3013
+ // 3. Fall back to all jobs (and optionally family-match against the endpoint).
3014
+ if (list.length === 0) {
3015
+ const allResult = await fetchWithRetry(() => api.listJobs());
3016
+ if (!active) return;
3017
+ if (!allResult.ok) {
3018
+ setError(allResult.error ?? "Failed to load training jobs.");
3019
+ setPhase("error");
3020
+ setTimeout(() => exit(), 500);
3021
+ return;
3022
+ }
3023
+ const allDeployable = sortByRecent(
3024
+ (allResult.data?.training_jobs ?? []).filter((j) => isJobDeployable(j.status))
3025
+ );
3026
+
3027
+ if (!showAll && selected) {
3028
+ const familyMatched = allDeployable.filter((j) => jobMatchesEndpoint(j, selected));
3029
+ if (familyMatched.length > 0) {
3030
+ list = familyMatched;
3031
+ didFilter = true;
3032
+ filterSource = "family";
3033
+ } else {
3034
+ list = allDeployable;
3035
+ }
3036
+ } else {
3037
+ list = allDeployable;
3038
+ }
3039
+ }
3040
+
3041
+ setFilterApplied(didFilter);
3042
+ setFilterSource(filterSource);
3043
+ setJobs(list);
3044
+ setPhase("picking");
3045
+ })();
3046
+ return () => {
3047
+ active = false;
3048
+ };
3049
+ }, [exit, modelId, showAll]);
3050
+
3051
+ const matching = jobs.filter((job) => {
3052
+ const q = searchQuery.trim().toLowerCase();
3053
+ if (!q) return true;
3054
+ return (
3055
+ (job.id ?? "").toLowerCase().includes(q) ||
3056
+ (job.model_name ?? "").toLowerCase().includes(q) ||
3057
+ (job.base_model ?? "").toLowerCase().includes(q)
3058
+ );
3059
+ });
3060
+
3061
+ const deploy = async (jobId: string) => {
3062
+ setSelectedJobId(jobId);
3063
+ setPhase("deploying");
3064
+
3065
+ // The deploy endpoint requires `training_jobs.project_id == project_id`
3066
+ // (server-side WHERE clause). Most jobs are created with project_id=null,
3067
+ // so we PATCH first to link the job to the target project. The PATCH is
3068
+ // a no-op if the job is already linked.
3069
+ const job = jobs.find((j) => j.id === jobId);
3070
+ if (!job || job.project_id !== modelId) {
3071
+ const patch = await api.updateTrainingJob(jobId, { project_id: modelId });
3072
+ if (!patch.ok) {
3073
+ const detail =
3074
+ patch.status === 403
3075
+ ? "You do not have permission to assign this job to this project."
3076
+ : patch.error ?? "Failed to link job to project.";
3077
+ setError(`Could not assign job to project before deploy: ${detail}`);
3078
+ setPhase("error");
3079
+ setTimeout(() => exit(), 1500);
3080
+ return;
3081
+ }
3082
+ }
3083
+
3084
+ const result = await api.deployTrainingJobToProject(modelId, {
3085
+ training_job_id: jobId,
3086
+ ...(reason ? { reason } : {}),
3087
+ });
3088
+ if (!result.ok) {
3089
+ let msg = result.error ?? "Deployment failed.";
3090
+ if (result.status === 404) {
3091
+ msg =
3092
+ `Backend rejected the deploy with 404 "${result.error ?? "Resource not found."}".\n` +
3093
+ ` • Project ${modelId} and training job ${jobId} both exist and are linked.\n` +
3094
+ ` • Inspect job state: pioneer job get ${jobId}`;
3095
+ }
3096
+ setError(msg);
3097
+ setPhase("error");
3098
+ } else {
3099
+ // CLI-side enrichment: the deploy record's `base_model` is null for
3100
+ // training-job deploys (mutually-exclusive with `training_job_id` in
3101
+ // the current schema), but users want to see what base the fine-tune
3102
+ // is built on. Populate it from the picked job until the backend
3103
+ // change lands.
3104
+ const enriched =
3105
+ result.data && typeof result.data === "object" && job?.base_model
3106
+ ? { ...result.data, base_model: (result.data as { base_model?: string | null }).base_model ?? job.base_model }
3107
+ : result.data;
3108
+ setDeployResult(enriched);
3109
+ setPhase("done");
3110
+ }
3111
+ setTimeout(() => exit(), 1500);
3112
+ };
3113
+
3114
+ useInput(
3115
+ (input, key) => {
3116
+ if (phase !== "picking" || !matching.length) {
3117
+ if (key.return && phase === "picking" && !matching.length) {
3118
+ setError("No matching jobs.");
3119
+ }
3120
+ return;
3121
+ }
3122
+ if (key.upArrow) {
3123
+ setHighlightIndex((idx) => (idx === 0 ? matching.length - 1 : idx - 1));
3124
+ return;
3125
+ }
3126
+ if (key.downArrow) {
3127
+ setHighlightIndex((idx) => (idx === matching.length - 1 ? 0 : idx + 1));
3128
+ return;
3129
+ }
3130
+ if (key.return) {
3131
+ const selected = matching[highlightIndex];
3132
+ if (selected?.id) {
3133
+ void deploy(selected.id);
3134
+ }
3135
+ return;
3136
+ }
3137
+ if (key.backspace || key.delete) {
3138
+ setSearchQuery((q) => q.slice(0, -1));
3139
+ setHighlightIndex(0);
3140
+ return;
3141
+ }
3142
+ if (input && !key.ctrl && !key.meta) {
3143
+ setSearchQuery((q) => q + input);
3144
+ setHighlightIndex(0);
3145
+ }
3146
+ },
3147
+ { isActive: isRawModeSupported && phase === "picking" }
3148
+ );
3149
+
3150
+ if (!isRawModeSupported) {
3151
+ return (
3152
+ <Box flexDirection="column">
3153
+ <ErrorMessage error="Interactive job picker requires a TTY." />
3154
+ <Text>List trained jobs with `pioneer job list` and pass an explicit ID:</Text>
3155
+ <Text dimColor> pioneer model endpoints deploy {modelId} --job &lt;training-job-id&gt;</Text>
3156
+ </Box>
3157
+ );
3158
+ }
1374
3159
 
1375
- if (error) {
3160
+ if (phase === "loading") return <Loading message="Loading deployable training jobs..." />;
3161
+ if (phase === "error") return <ErrorMessage error={error} />;
3162
+
3163
+ if (phase === "deploying") {
3164
+ return <Loading message={`Deploying job ${selectedJobId} to endpoint ${modelId}...`} />;
3165
+ }
3166
+
3167
+ if (phase === "done") {
1376
3168
  return (
1377
3169
  <Box flexDirection="column">
1378
- <ErrorMessage error={error} />
1379
- <Text> </Text>
1380
- <Text dimColor>Try again with --model explicitly, or check your API connectivity.</Text>
3170
+ <Success message={`Deployment initiated for endpoint ${modelId} from job ${selectedJobId}`} />
3171
+ {deployResult ? (
3172
+ <Text dimColor>{JSON.stringify(deployResult, null, 2)}</Text>
3173
+ ) : null}
1381
3174
  </Box>
1382
3175
  );
1383
3176
  }
1384
3177
 
1385
- if (selectedModelId) {
3178
+ if (jobs.length === 0) {
1386
3179
  return (
1387
- <ApiCommand
1388
- action={() =>
1389
- api.createProject({
1390
- name: name ?? selectedModelId,
1391
- ...(icon ? { icon } : {}),
1392
- ...(repo ? { repo } : {}),
1393
- ...(description ? { description } : {}),
1394
- active_model_id: selectedModelId,
1395
- selected_model_id: selectedModelId,
1396
- ...(example ? { example } : {}),
1397
- })
1398
- }
1399
- successMessage="Model entry created"
1400
- />
3180
+ <Box flexDirection="column">
3181
+ <ErrorMessage error="No deployable training jobs found." />
3182
+ <Text dimColor>Only jobs with status `complete` / `succeeded` / `deployed` are eligible.</Text>
3183
+ <Text dimColor>Run `pioneer job list` to inspect job statuses, or start training with `pioneer agent`.</Text>
3184
+ </Box>
1401
3185
  );
1402
3186
  }
1403
3187
 
1404
- if (loading) {
1405
- return <Loading message="Loading supported models..." />;
1406
- }
3188
+ const visible = matching.slice(0, 12);
3189
+ const offsetIndex = Math.max(0, Math.min(highlightIndex - visible.length + 1, matching.length - visible.length));
3190
+ const window = matching.slice(offsetIndex, offsetIndex + visible.length);
3191
+
3192
+ const navHint = "Type to filter · ↑/↓ navigate · Enter to deploy";
3193
+ const headerLine = (() => {
3194
+ const n = jobs.length;
3195
+ const noun = `job${n === 1 ? "" : "s"}`;
3196
+ if (showAll) {
3197
+ const tail = endpointModel ? ` Endpoint base model: "${endpointModel}".` : "";
3198
+ return `Showing all ${n} deployable ${noun} (--all).${tail} ${navHint}.`;
3199
+ }
3200
+ if (filterApplied && filterSource === "project") {
3201
+ return `Showing ${n} ${noun} scoped to this endpoint via project_id. ${navHint} · pass --all to see every job.`;
3202
+ }
3203
+ if (filterApplied && filterSource === "family" && endpointModel) {
3204
+ return `Showing ${n} ${noun} matching base model "${endpointModel}". ${navHint} · pass --all to disable family match.`;
3205
+ }
3206
+ if (endpointModel) {
3207
+ return `No jobs scoped to this endpoint and none match base model "${endpointModel}". Showing all ${n} deployable ${noun} as fallback. ${navHint}.`;
3208
+ }
3209
+ return `Showing ${n} deployable ${noun}. ${navHint}.`;
3210
+ })();
1407
3211
 
1408
3212
  return (
1409
3213
  <Box flexDirection="column">
1410
- <Text bold>Choose a base model for this entry</Text>
1411
- <Text>Type to filter. Use ↑/↓ to navigate and Enter to select.</Text>
1412
- <Text dimColor>Type any part of model id, name, label, or description.</Text>
1413
- <Text> </Text>
1414
- <TextInput
1415
- value={query}
1416
- onChange={(value) => setQuery(value)}
1417
- onSubmit={(value) => {
1418
- const resolvedModel = resolveModelId(value);
1419
- if (!resolvedModel) {
1420
- setError("No matching models found. Refine your search and try again.");
1421
- return;
1422
- }
1423
- setSelectedModelId(resolvedModel);
1424
- }}
1425
- placeholder="e.g. qwen/qwen3-8b"
1426
- />
3214
+ <Text bold>Pick a training job to deploy to endpoint <Text color="cyan">{modelId}</Text>:</Text>
3215
+ <Text dimColor>{headerLine}</Text>
1427
3216
  <Text> </Text>
1428
- {topMatches.length === 0 ? (
1429
- <Text dimColor>No matching models found.</Text>
3217
+ {searchQuery ? (
3218
+ <Text>
3219
+ <Text dimColor>Filter: </Text>
3220
+ <Text>{searchQuery}</Text>
3221
+ </Text>
3222
+ ) : null}
3223
+ {window.length === 0 ? (
3224
+ <Text color="yellow">No jobs match `{searchQuery}`.</Text>
1430
3225
  ) : (
1431
- <Box flexDirection="column">
1432
- {topMatches.map((model, index) => {
1433
- const suffix = [model.label, model.task_type, model.type].filter(Boolean).join(" · ");
1434
- const modelLine = `${model.id}${suffix ? ` (${suffix})` : ""}`;
1435
- const isHighlighted = index === highlightIndex;
1436
- return (
1437
- <Text key={model.id} color={isHighlighted ? "cyan" : undefined} bold={isHighlighted}>
1438
- {isHighlighted ? "▶ " : " "}
1439
- {modelLine}
1440
- </Text>
1441
- );
1442
- })}
1443
- </Box>
3226
+ window.map((job, idx) => {
3227
+ const realIndex = offsetIndex + idx;
3228
+ const isHighlighted = realIndex === highlightIndex;
3229
+ const idShort = shortJobId(job.id);
3230
+ const modelName = job.model_name?.trim() || "—";
3231
+ const baseModel = shortBaseModel(job.base_model);
3232
+ const status = job.status ?? "";
3233
+ const completed = relativeTime(job.completed_at ?? job.updated_at ?? job.created_at);
3234
+ return (
3235
+ <Text key={job.id || realIndex} color={isHighlighted ? "cyan" : undefined} bold={isHighlighted}>
3236
+ {isHighlighted ? "▶ " : " "}
3237
+ {padCell(idShort, 8)} {padCell(modelName, 28)} {padCell(baseModel, 22)} <Text color={statusColor(status)}>{padCell(status, 10)}</Text> {completed}
3238
+ </Text>
3239
+ );
3240
+ })
1444
3241
  )}
1445
- <Text> </Text>
1446
- <Text dimColor>Press Enter to select the highlighted model.</Text>
1447
- <Text dimColor> </Text>
1448
- <Text dimColor>Tip: You can still run {"model endpoints create --model \"<base-model-id>\""} for exact model ids.</Text>
3242
+ {error ? (
3243
+ <Box marginTop={1}>
3244
+ <Text color="red">{error}</Text>
3245
+ </Box>
3246
+ ) : null}
1449
3247
  </Box>
1450
3248
  );
1451
3249
  }
1452
3250
 
3251
+ // ─────────────────────────────────────────────────────────────────────────────
3252
+ // Model Artifact Download Command (key/value layout for a download URL response)
3253
+ // ─────────────────────────────────────────────────────────────────────────────
3254
+
3255
+ function ModelArtifactDownloadCommand({ jobId }: { jobId: string }) {
3256
+ const { exit } = useApp();
3257
+ const [state, setState] = useState<"loading" | "done" | "error">("loading");
3258
+ const [data, setData] = useState<Record<string, unknown> | null>(null);
3259
+ const [error, setError] = useState("");
3260
+
3261
+ useEffect(() => {
3262
+ let active = true;
3263
+ (async () => {
3264
+ const result = await api.downloadModel(jobId);
3265
+ if (!active) return;
3266
+ if (!result.ok) {
3267
+ setError(result.error ?? "Failed to fetch model artifact download URL.");
3268
+ setState("error");
3269
+ } else {
3270
+ setData((result.data as Record<string, unknown> | undefined) ?? null);
3271
+ setState("done");
3272
+ }
3273
+ setTimeout(() => exit(), 500);
3274
+ })();
3275
+ return () => {
3276
+ active = false;
3277
+ };
3278
+ }, [jobId, exit]);
3279
+
3280
+ if (state === "loading") return <Loading message={`Fetching download URL for ${jobId}...`} />;
3281
+ if (state === "error") return <ErrorMessage error={error} />;
3282
+ if (!data) return <ErrorMessage error="Download response was empty." />;
3283
+
3284
+ const stringField = (key: string) => {
3285
+ const v = data[key];
3286
+ return typeof v === "string" && v.trim() ? v : undefined;
3287
+ };
3288
+ const numberField = (key: string) => {
3289
+ const v = data[key];
3290
+ return typeof v === "number" ? v : undefined;
3291
+ };
3292
+
3293
+ const downloadUrl = stringField("download_url") || stringField("url");
3294
+ const expiresAt = stringField("expires_at") || stringField("expiry");
3295
+ const sizeBytes = numberField("size") ?? numberField("size_bytes");
3296
+ const filename = stringField("filename") || stringField("file_name");
3297
+ const contentType = stringField("content_type") || stringField("mime_type");
3298
+
3299
+ const rows: DetailRow[] = [
3300
+ { label: "Job ID", value: jobId },
3301
+ { label: "Filename", value: formatNonEmpty(filename) },
3302
+ { label: "Content type", value: formatNonEmpty(contentType) },
3303
+ { label: "Size", value: sizeBytes !== undefined ? `${sizeBytes.toLocaleString()} bytes` : "—" },
3304
+ { label: "Expires", value: expiresAt ? formatTimestamp(expiresAt) : "—" },
3305
+ { label: "Download URL", value: downloadUrl || "—", valueColor: downloadUrl ? "cyan" : undefined },
3306
+ ];
3307
+
3308
+ return (
3309
+ <DetailView
3310
+ rows={rows}
3311
+ footer={
3312
+ downloadUrl
3313
+ ? `Tip: pipe the URL into curl, e.g. \`curl -L -o artifact.zip "$URL"\` · use \`--json\` for raw JSON`
3314
+ : `Use \`pioneer model artifacts download ${jobId} --json\` for the raw JSON payload.`
3315
+ }
3316
+ />
3317
+ );
3318
+ }
3319
+
1453
3320
  // ─────────────────────────────────────────────────────────────────────────────
1454
3321
  // Job Logs Command (prettified output)
1455
3322
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1651,462 +3518,195 @@ function GenerateCommand<T extends GenerateResult>({
1651
3518
  <Text color="cyan">Saved to: {savedPath}</Text>
1652
3519
  )}
1653
3520
  </Box>
1654
- );
1655
- }
1656
-
1657
- // ─────────────────────────────────────────────────────────────────────────────
1658
- // Helper: Infer format from file extension
1659
- // ─────────────────────────────────────────────────────────────────────────────
1660
-
1661
- function inferFormatFromPath(
1662
- path: string | undefined,
1663
- defaultFormat: string = "jsonl"
1664
- ): "csv" | "jsonl" | "parquet" {
1665
- if (!path) return defaultFormat as "csv" | "jsonl" | "parquet";
1666
-
1667
- const ext = path.toLowerCase().split(".").pop();
1668
- if (ext === "csv" || ext === "jsonl" || ext === "parquet") {
1669
- return ext;
1670
- }
1671
- return defaultFormat as "csv" | "jsonl" | "parquet";
1672
- }
1673
-
1674
- // ─────────────────────────────────────────────────────────────────────────────
1675
- // Dataset List Command (shows both remote and local)
1676
- // ─────────────────────────────────────────────────────────────────────────────
1677
-
1678
- function DatasetListCommand() {
1679
- const { exit } = useApp();
1680
- const [state, setState] = useState<"loading" | "done">("loading");
1681
- const [remoteData, setRemoteData] = useState<api.DatasetListResponse | null>(null);
1682
- const [remoteError, setRemoteError] = useState<string | null>(null);
1683
- const [localDatasets, setLocalDatasets] = useState<LocalDataset[]>([]);
1684
-
1685
- useEffect(() => {
1686
- (async () => {
1687
- // Fetch remote and local datasets
1688
- const result = await api.listDatasets();
1689
- const local = listLocalDatasets();
1690
- setLocalDatasets(local);
1691
-
1692
- if (result.ok) {
1693
- setRemoteData(result.data ?? null);
1694
- } else {
1695
- setRemoteError(result.error ?? "Unknown error");
1696
- }
1697
- setState("done");
1698
- setTimeout(() => exit(), 500);
1699
- })();
1700
- }, [exit]);
1701
-
1702
- if (state === "loading") {
1703
- return <Loading />;
1704
- }
1705
-
1706
- const remoteDatasets = remoteData?.datasets ?? [];
1707
-
1708
- return (
1709
- <Box flexDirection="column">
1710
- <Text bold color="cyan">Remote Datasets {remoteError ? "" : `(${remoteDatasets.length})`}</Text>
1711
- {remoteError ? (
1712
- <Box flexDirection="column">
1713
- {remoteError.split("\n").map((line, idx) => (
1714
- <Text key={idx} color="red"> {line}</Text>
1715
- ))}
1716
- </Box>
1717
- ) : remoteDatasets.length === 0 ? (
1718
- <Text dimColor> No remote datasets</Text>
1719
- ) : (
1720
- remoteDatasets.map((ds) => (
1721
- <Box key={ds.id} flexDirection="column">
1722
- <Text>
1723
- {" "}<Text color="yellow">{ds.dataset_name}:{ds.version_number || "v1"}</Text> <Text dimColor>({ds.dataset_type}, {ds.sample_size} examples)</Text>
1724
- </Text>
1725
- <Text dimColor> {ds.id}</Text>
1726
- </Box>
1727
- ))
1728
- )}
1729
- <Text> </Text>
1730
- <Text bold color="cyan">Local Datasets ({localDatasets.length})</Text>
1731
- {localDatasets.length === 0 ? (
1732
- <Text dimColor> No local datasets in ./datasets/</Text>
1733
- ) : (
1734
- localDatasets.map((ds) => (
1735
- <Text key={ds.id}>
1736
- {" "}<Text color="green">{ds.name}</Text> <Text dimColor>({ds.type}, {ds.sample_size} examples)</Text>
1737
- </Text>
1738
- ))
1739
- )}
1740
- </Box>
1741
- );
1742
- }
1743
-
1744
- // ─────────────────────────────────────────────────────────────────────────────
1745
- // Job Create Command (with auto-upload for local datasets)
1746
- // ─────────────────────────────────────────────────────────────────────────────
1747
-
1748
- interface ParsedDataset {
1749
- type: "local" | "remote";
1750
- name?: string; // for remote name:version format
1751
- version?: string; // for remote name:version format
1752
- id?: string; // for remote UUID format
1753
- }
1754
-
1755
- interface JobCreateCommandProps {
1756
- modelName: string;
1757
- datasets: ParsedDataset[];
1758
- baseModel?: string;
1759
- epochs?: number;
1760
- }
1761
-
1762
- interface DatasetUploadStatus {
1763
- name: string;
1764
- path: string;
1765
- status: "pending" | "uploading" | "done" | "error";
1766
- error?: string;
1767
- uploadedRef?: api.DatasetRef;
1768
- }
1769
-
1770
- // UUID pattern: 8-4-4-4-12 hex characters
1771
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1772
-
1773
- function isUUID(str: string): boolean {
1774
- return UUID_PATTERN.test(str);
1775
- }
1776
-
1777
- function parseDatasetString(ds: string): ParsedDataset | { error: string } {
1778
- const trimmed = ds.trim();
1779
-
1780
- if (trimmed.startsWith("local:")) {
1781
- const name = trimmed.slice(6); // remove "local:"
1782
- if (!name) {
1783
- return { error: `Invalid local dataset format: ${ds}. Use local:<name>` };
1784
- }
1785
- return { type: "local", name };
1786
- }
1787
-
1788
- if (trimmed.startsWith("remote:")) {
1789
- const rest = trimmed.slice(7); // remove "remote:"
1790
-
1791
- // Check if it's a UUID
1792
- if (isUUID(rest)) {
1793
- return { type: "remote", id: rest };
1794
- }
1795
-
1796
- // Otherwise parse as name:version
1797
- const colonIndex = rest.lastIndexOf(":");
1798
- if (colonIndex === -1) {
1799
- // No version, default to latest
1800
- return { type: "remote", name: rest, version: "latest" };
1801
- }
1802
- return {
1803
- type: "remote",
1804
- name: rest.slice(0, colonIndex),
1805
- version: rest.slice(colonIndex + 1),
1806
- };
1807
- }
1808
-
1809
- return { error: `Invalid dataset format: ${ds}. Use remote:<name>:<version>, remote:<uuid>, or local:<name>` };
1810
- }
1811
-
1812
- function resolveLocalDatasetPath(name: string): string | null {
1813
- // Check if it's a local dataset by name (in ./datasets/ directory)
1814
- const localPath = path.join(DATASETS_DIR, `${name}.json`);
1815
- if (fs.existsSync(localPath)) {
1816
- return localPath;
1817
- }
1818
- // Check if it's a direct file path
1819
- if (fs.existsSync(name) && (name.endsWith(".json") || name.endsWith(".jsonl"))) {
1820
- return name;
1821
- }
1822
- return null;
1823
- }
1824
-
1825
- function getLocalDatasetType(filePath: string): "ner" | "classification" | "custom" {
1826
- try {
1827
- const content = fs.readFileSync(filePath, "utf-8");
1828
- const parsed = JSON.parse(content);
1829
- const data = Array.isArray(parsed) ? parsed : parsed.data ?? [];
1830
- if (parsed.task_type) {
1831
- return parsed.task_type as "ner" | "classification" | "custom";
1832
- }
1833
- const firstItem = data[0] as Record<string, unknown> | undefined;
1834
- if (firstItem?.spans) return "ner";
1835
- if (firstItem?.label) return "classification";
1836
- return "custom";
1837
- } catch {
1838
- return "custom";
1839
- }
1840
- }
1841
-
1842
- function convertJsonToJsonl(filePath: string): string {
1843
- // Convert JSON array to JSONL format for upload
1844
- const content = fs.readFileSync(filePath, "utf-8");
1845
- const parsed = JSON.parse(content);
1846
- const data = Array.isArray(parsed) ? parsed : parsed.data ?? [];
1847
-
1848
- // Create temp JSONL file
1849
- const tempPath = filePath.replace(".json", ".jsonl");
1850
- const jsonlContent = data.map((item: unknown) => JSON.stringify(item)).join("\n");
1851
- fs.writeFileSync(tempPath, jsonlContent);
1852
- return tempPath;
1853
- }
1854
-
1855
- function JobCreateCommand({ modelName, datasets, baseModel, epochs }: JobCreateCommandProps) {
1856
- const { exit } = useApp();
1857
- const [state, setState] = useState<"resolving" | "uploading" | "creating" | "done" | "error">("resolving");
1858
- const [uploadStatuses, setUploadStatuses] = useState<DatasetUploadStatus[]>([]);
1859
- const [jobResult, setJobResult] = useState<api.TrainingJob | null>(null);
1860
- const [error, setError] = useState("");
1861
-
1862
- useEffect(() => {
1863
- (async () => {
1864
- // Separate local and remote datasets
1865
- const localDatasets = datasets.filter((d): d is ParsedDataset & { type: "local"; name: string } =>
1866
- d.type === "local" && !!d.name
1867
- );
1868
- const remoteByNameVersion = datasets.filter((d): d is ParsedDataset & { type: "remote"; name: string } =>
1869
- d.type === "remote" && !!d.name
1870
- );
1871
- const remoteByUUID = datasets.filter((d): d is ParsedDataset & { type: "remote"; id: string } =>
1872
- d.type === "remote" && !!d.id
1873
- );
1874
-
1875
- // Resolve UUIDs to name:version
1876
- const remoteDatasets: api.DatasetRef[] = remoteByNameVersion.map(d => ({
1877
- name: d.name,
1878
- version: d.version ?? "latest"
1879
- }));
1880
-
1881
- if (remoteByUUID.length > 0) {
1882
- // Fetch dataset list to resolve UUIDs
1883
- const listResult = await api.listDatasets();
1884
- if (!listResult.ok || !listResult.data) {
1885
- setError(`Failed to resolve dataset UUIDs: ${listResult.error ?? "Unknown error"}`);
1886
- setState("error");
1887
- setTimeout(() => exit(), 500);
1888
- return;
1889
- }
1890
-
1891
- for (const uuidDataset of remoteByUUID) {
1892
- const found = listResult.data.datasets.find(ds => ds.id === uuidDataset.id);
1893
- if (!found) {
1894
- setError(`Dataset not found with UUID: ${uuidDataset.id}`);
1895
- setState("error");
1896
- setTimeout(() => exit(), 500);
1897
- return;
1898
- }
1899
- remoteDatasets.push({
1900
- name: found.dataset_name,
1901
- version: found.version_number ?? "latest",
1902
- });
1903
- }
1904
- }
1905
-
1906
- // Validate local datasets exist
1907
- const statuses: DatasetUploadStatus[] = [];
1908
- for (const local of localDatasets) {
1909
- const localPath = resolveLocalDatasetPath(local.name);
1910
- if (!localPath) {
1911
- setError(`Local dataset not found: ${local.name}`);
1912
- setState("error");
1913
- setTimeout(() => exit(), 500);
1914
- return;
1915
- }
1916
- statuses.push({ name: local.name, path: localPath, status: "pending" });
1917
- }
1918
-
1919
- if (statuses.length === 0) {
1920
- // No local datasets, proceed directly to job creation
1921
- setState("creating");
1922
- const result = await api.createJob({
1923
- model_name: modelName,
1924
- datasets: remoteDatasets,
1925
- base_model: baseModel,
1926
- nr_epochs: epochs,
1927
- });
1928
- if (result.ok && result.data) {
1929
- setJobResult(result.data);
1930
- setState("done");
1931
- } else {
1932
- setError(result.error ?? "Failed to create job");
1933
- setState("error");
1934
- }
1935
- setTimeout(() => exit(), 500);
1936
- return;
1937
- }
1938
-
1939
- // Upload local datasets
1940
- setUploadStatuses(statuses);
1941
- setState("uploading");
1942
-
1943
- const uploadedDatasets: api.DatasetRef[] = [...remoteDatasets];
1944
- let hasError = false;
3521
+ );
3522
+ }
1945
3523
 
1946
- for (let i = 0; i < statuses.length; i++) {
1947
- const status = statuses[i];
3524
+ // ─────────────────────────────────────────────────────────────────────────────
3525
+ // Helper: Infer format from file extension
3526
+ // ─────────────────────────────────────────────────────────────────────────────
1948
3527
 
1949
- // Update status to uploading
1950
- setUploadStatuses(prev => prev.map((s, idx) =>
1951
- idx === i ? { ...s, status: "uploading" as const } : s
1952
- ));
3528
+ function inferFormatFromPath(
3529
+ path: string | undefined,
3530
+ defaultFormat: string = "jsonl"
3531
+ ): "csv" | "jsonl" | "parquet" {
3532
+ if (!path) return defaultFormat as "csv" | "jsonl" | "parquet";
1953
3533
 
1954
- try {
1955
- // Convert JSON to JSONL if needed
1956
- let uploadPath = status.path;
1957
- let isTemp = false;
1958
- if (status.path.endsWith(".json")) {
1959
- uploadPath = convertJsonToJsonl(status.path);
1960
- isTemp = true;
1961
- }
3534
+ const ext = path.toLowerCase().split(".").pop();
3535
+ if (ext === "csv" || ext === "jsonl" || ext === "parquet") {
3536
+ return ext;
3537
+ }
3538
+ return defaultFormat as "csv" | "jsonl" | "parquet";
3539
+ }
1962
3540
 
1963
- const result = await api.uploadDataset(uploadPath, {
1964
- dataset_name: status.name,
1965
- dataset_type: getLocalDatasetType(status.path),
1966
- format: "jsonl",
1967
- });
3541
+ // ─────────────────────────────────────────────────────────────────────────────
3542
+ // Dataset List Command (tabular: remote + local in one table)
3543
+ // ─────────────────────────────────────────────────────────────────────────────
1968
3544
 
1969
- // Clean up temp file
1970
- if (isTemp && uploadPath !== status.path) {
1971
- try { fs.unlinkSync(uploadPath); } catch { /* ignore */ }
1972
- }
3545
+ interface DatasetRow {
3546
+ source: string;
3547
+ id: string;
3548
+ name: string;
3549
+ version: string;
3550
+ type: string;
3551
+ samples: string;
3552
+ created: string;
3553
+ [key: string]: string;
3554
+ }
1973
3555
 
1974
- if (result.ok && result.data) {
1975
- const uploadedRef: api.DatasetRef = {
1976
- name: result.data.dataset_name,
1977
- version: result.data.version_number ?? "latest",
1978
- };
1979
- uploadedDatasets.push(uploadedRef);
1980
- setUploadStatuses(prev => prev.map((s, idx) =>
1981
- idx === i ? { ...s, status: "done" as const, uploadedRef } : s
1982
- ));
1983
- } else {
1984
- hasError = true;
1985
- setUploadStatuses(prev => prev.map((s, idx) =>
1986
- idx === i ? { ...s, status: "error" as const, error: result.error } : s
1987
- ));
1988
- }
1989
- } catch (err) {
1990
- hasError = true;
1991
- setUploadStatuses(prev => prev.map((s, idx) =>
1992
- idx === i ? { ...s, status: "error" as const, error: err instanceof Error ? err.message : String(err) } : s
1993
- ));
1994
- }
1995
- }
3556
+ function shortDatasetId(id?: string): string {
3557
+ if (!id) return "—";
3558
+ return id.length > 8 ? id.slice(0, 8) : id;
3559
+ }
1996
3560
 
1997
- if (hasError) {
1998
- setError("Some datasets failed to upload");
1999
- setState("error");
2000
- setTimeout(() => exit(), 500);
2001
- return;
2002
- }
3561
+ function formatSampleCount(n?: number): string {
3562
+ if (typeof n !== "number" || Number.isNaN(n)) return "—";
3563
+ return n.toLocaleString();
3564
+ }
2003
3565
 
2004
- // Create the job with all datasets
2005
- setState("creating");
3566
+ const DATASET_COLUMNS: TableColumn<DatasetRow>[] = [
3567
+ { key: "source", header: "SOURCE", minWidth: 6, maxWidth: 6, color: (row) => (row.source === "remote" ? "cyan" : "green") },
3568
+ { key: "id", header: "ID", minWidth: 8, maxWidth: 8 },
3569
+ { key: "name", header: "NAME", minWidth: 10, maxWidth: 38, flexible: true },
3570
+ { key: "version", header: "VERSION", minWidth: 4, maxWidth: 8 },
3571
+ { key: "type", header: "TYPE", minWidth: 4, maxWidth: 14, flexible: true },
3572
+ { key: "samples", header: "SAMPLES", minWidth: 7, maxWidth: 10 },
3573
+ { key: "created", header: "CREATED", minWidth: 7, maxWidth: 10 },
3574
+ ];
2006
3575
 
2007
- const result = await api.createJob({
2008
- model_name: modelName,
2009
- datasets: uploadedDatasets,
2010
- base_model: baseModel,
2011
- nr_epochs: epochs,
2012
- });
3576
+ function DatasetListCommand() {
3577
+ const { exit } = useApp();
3578
+ const [state, setState] = useState<"loading" | "done">("loading");
3579
+ const [remoteData, setRemoteData] = useState<api.DatasetListResponse | null>(null);
3580
+ const [remoteError, setRemoteError] = useState<string | null>(null);
3581
+ const [localDatasets, setLocalDatasets] = useState<LocalDataset[]>([]);
2013
3582
 
2014
- if (result.ok && result.data) {
2015
- setJobResult(result.data);
2016
- setState("done");
2017
- } else {
2018
- setError(result.error ?? "Failed to create job");
2019
- setState("error");
2020
- }
3583
+ useEffect(() => {
3584
+ (async () => {
3585
+ const result = await api.listDatasets();
3586
+ setLocalDatasets(listLocalDatasets());
3587
+ if (result.ok) setRemoteData(result.data ?? null);
3588
+ else setRemoteError(result.error ?? "Unknown error");
3589
+ setState("done");
2021
3590
  setTimeout(() => exit(), 500);
2022
3591
  })();
2023
- }, [modelName, datasets, baseModel, epochs, exit]);
2024
-
2025
- if (state === "resolving") {
2026
- return <Loading message="Resolving datasets..." />;
2027
- }
2028
-
2029
- if (state === "uploading") {
2030
- return (
2031
- <Box flexDirection="column">
2032
- <Text>
2033
- <Text color="blue"><Spinner type="dots" /></Text>
2034
- {" "}Uploading local datasets...
2035
- </Text>
2036
- {uploadStatuses.map((status, idx) => (
2037
- <Box key={idx}>
2038
- <Text>
2039
- {" "}
2040
- {status.status === "pending" && <Text color="gray">○</Text>}
2041
- {status.status === "uploading" && <Text color="yellow"><Spinner type="dots" /></Text>}
2042
- {status.status === "done" && <Text color="green">✓</Text>}
2043
- {status.status === "error" && <Text color="red">✗</Text>}
2044
- {" "}{status.name}
2045
- {status.status === "done" && status.uploadedRef && (
2046
- <Text color="gray"> → {status.uploadedRef.name}:{status.uploadedRef.version}</Text>
2047
- )}
2048
- {status.status === "error" && status.error && (
2049
- <Text color="red"> ({status.error})</Text>
2050
- )}
2051
- </Text>
2052
- </Box>
2053
- ))}
2054
- </Box>
2055
- );
2056
- }
3592
+ }, [exit]);
2057
3593
 
2058
- if (state === "creating") {
2059
- return <Loading message="Creating training job..." />;
2060
- }
3594
+ if (state === "loading") return <Loading />;
2061
3595
 
2062
- if (state === "error") {
3596
+ const remoteDatasets = remoteData?.datasets ?? [];
3597
+ const remoteRows: DatasetRow[] = remoteDatasets.map((ds) => ({
3598
+ source: "remote",
3599
+ id: shortDatasetId(ds.id),
3600
+ name: ds.dataset_name || "—",
3601
+ version: ds.version_number ? `v${ds.version_number}` : "v1",
3602
+ type: ds.dataset_type || "—",
3603
+ samples: formatSampleCount(ds.sample_size),
3604
+ created: relativeTime(ds.created_at),
3605
+ }));
3606
+ const localRows: DatasetRow[] = localDatasets.map((ds) => ({
3607
+ source: "local",
3608
+ id: "—",
3609
+ name: ds.name,
3610
+ version: "—",
3611
+ type: ds.type || "—",
3612
+ samples: formatSampleCount(ds.sample_size),
3613
+ created: "—",
3614
+ }));
3615
+ const rows = [...remoteRows, ...localRows];
3616
+
3617
+ if (rows.length === 0 && !remoteError) {
2063
3618
  return (
2064
3619
  <Box flexDirection="column">
2065
- {uploadStatuses.length > 0 && uploadStatuses.some(s => s.status === "done") && (
2066
- <Box flexDirection="column" marginBottom={1}>
2067
- <Text color="green">✓ Uploaded datasets:</Text>
2068
- {uploadStatuses.filter(s => s.status === "done").map((status, idx) => (
2069
- <Text key={idx} color="gray">
2070
- {" "}{status.name} → {status.uploadedRef?.name}:{status.uploadedRef?.version}
2071
- </Text>
2072
- ))}
2073
- </Box>
2074
- )}
2075
- <ErrorMessage error={error} />
3620
+ <Text>No datasets found.</Text>
3621
+ <Text dimColor>Use the Pioneer web app at agent.pioneer.ai to create datasets, or drop JSON/JSONL files in ./datasets/.</Text>
2076
3622
  </Box>
2077
3623
  );
2078
3624
  }
2079
3625
 
2080
3626
  return (
2081
3627
  <Box flexDirection="column">
2082
- {uploadStatuses.length > 0 && (
2083
- <Box flexDirection="column" marginBottom={1}>
2084
- <Text color="green">✓ Uploaded local datasets:</Text>
2085
- {uploadStatuses.map((status, idx) => (
2086
- <Text key={idx} color="gray">
2087
- {" "}{status.name} → {status.uploadedRef?.name}:{status.uploadedRef?.version}
2088
- </Text>
3628
+ {remoteError ? (
3629
+ <Box flexDirection="column" marginBottom={rows.length ? 1 : 0}>
3630
+ {remoteError.split("\n").map((line, idx) => (
3631
+ <Text key={idx} color="red">{line}</Text>
2089
3632
  ))}
2090
3633
  </Box>
2091
- )}
2092
- <Success message="Training job created" />
2093
- {jobResult && (
2094
- <Box flexDirection="column">
2095
- <Text>
2096
- {" "}Job ID: <Text color="cyan">{jobResult.id}</Text>
2097
- </Text>
2098
- <Text>
2099
- {" "}Model: <Text color="yellow">{jobResult.model_name}</Text>
2100
- </Text>
2101
- <Text>
2102
- {" "}Status: <Text color="blue">{jobResult.status}</Text>
2103
- </Text>
2104
- </Box>
2105
- )}
3634
+ ) : null}
3635
+ {rows.length > 0 ? (
3636
+ <DataTable
3637
+ columns={DATASET_COLUMNS}
3638
+ rows={rows}
3639
+ footer={`${remoteRows.length} remote · ${localRows.length} local · use \`pioneer dataset get <name[:version]>\` for details · use \`--json\` for raw JSON`}
3640
+ />
3641
+ ) : null}
2106
3642
  </Box>
2107
3643
  );
2108
3644
  }
2109
3645
 
3646
+ // ─────────────────────────────────────────────────────────────────────────────
3647
+ // Dataset Get Command (single-resource key/value layout)
3648
+ // ─────────────────────────────────────────────────────────────────────────────
3649
+
3650
+ function datasetToDetailRows(ds: api.Dataset): DetailRow[] {
3651
+ const rows: DetailRow[] = [
3652
+ { label: "ID", value: formatNonEmpty(ds.id) },
3653
+ { label: "Name", value: formatNonEmpty(ds.dataset_name) },
3654
+ { label: "Version", value: ds.version_number ? `v${ds.version_number}` : "—" },
3655
+ { label: "Type", value: formatNonEmpty(ds.dataset_type) },
3656
+ { label: "Status", value: formatNonEmpty(ds.status), valueColor: statusColor(ds.status) },
3657
+ { label: "Samples", value: formatSampleCount(ds.sample_size) },
3658
+ { label: "Train ratio", value: ds.train_ratio != null ? formatPercent(ds.train_ratio) : "—" },
3659
+ { label: "Visibility", value: formatNonEmpty(ds.visibility) },
3660
+ { label: "Labels", value: formatLabels(ds.labels) },
3661
+ { label: "Annotation status", value: formatNonEmpty(ds.annotation_status) },
3662
+ { label: "Project ID", value: formatNonEmpty(ds.project_id) },
3663
+ { label: "Root dataset ID", value: formatNonEmpty(ds.root_dataset_id) },
3664
+ { label: "Created", value: formatTimestamp(ds.created_at) },
3665
+ { label: "Updated", value: formatTimestamp(ds.updated_at) },
3666
+ ];
3667
+ if (ds.processing_error) {
3668
+ rows.push({ label: "Error", value: ds.processing_error, valueColor: "red" });
3669
+ }
3670
+ return rows;
3671
+ }
3672
+
3673
+ function DatasetGetCommand({ dataset }: { dataset: api.DatasetRef }) {
3674
+ const { exit } = useApp();
3675
+ const [state, setState] = useState<"loading" | "done" | "error">("loading");
3676
+ const [data, setData] = useState<api.Dataset | null>(null);
3677
+ const [error, setError] = useState("");
3678
+
3679
+ useEffect(() => {
3680
+ let active = true;
3681
+ (async () => {
3682
+ const result = await api.getDataset(dataset);
3683
+ if (!active) return;
3684
+ if (!result.ok) {
3685
+ setError(result.error ?? "Failed to load dataset.");
3686
+ setState("error");
3687
+ } else {
3688
+ setData(result.data ?? null);
3689
+ setState("done");
3690
+ }
3691
+ setTimeout(() => exit(), 500);
3692
+ })();
3693
+ return () => {
3694
+ active = false;
3695
+ };
3696
+ }, [dataset.name, dataset.version, exit]);
3697
+
3698
+ if (state === "loading") return <Loading message={`Loading dataset ${dataset.name}:${dataset.version}...`} />;
3699
+ if (state === "error") return <ErrorMessage error={error} />;
3700
+ if (!data) return <ErrorMessage error="Dataset response was empty." />;
3701
+
3702
+ return (
3703
+ <DetailView
3704
+ rows={datasetToDetailRows(data)}
3705
+ footer={`Use \`pioneer dataset get ${dataset.name}:${dataset.version} --json\` for the raw JSON payload.`}
3706
+ />
3707
+ );
3708
+ }
3709
+
2110
3710
  // ─────────────────────────────────────────────────────────────────────────────
2111
3711
  // Dataset Download Command
2112
3712
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2282,13 +3882,6 @@ function TrainedModelCard({ model, index }: TrainedModelCardProps) {
2282
3882
  <Box width={24}>
2283
3883
  <Text color="blue">{datasetInfo.substring(0, 22)}</Text>
2284
3884
  </Box>
2285
- <Box width={14}>
2286
- <Text>
2287
- <Text color="yellow">{model.nr_epochs}</Text>
2288
- <Text dimColor>e </Text>
2289
- <Text color="yellow">{model.learning_rate}</Text>
2290
- </Text>
2291
- </Box>
2292
3885
  <Box width={30}>
2293
3886
  <Text color="green">{metricsDisplay || "N/A"}</Text>
2294
3887
  </Box>
@@ -2299,11 +3892,6 @@ function TrainedModelCard({ model, index }: TrainedModelCardProps) {
2299
3892
  <Text dimColor>{formatDateShort(model.completed_at || model.started_at || model.created_at)}</Text>
2300
3893
  </Box>
2301
3894
  </Box>
2302
- {model.error_message && (
2303
- <Box marginLeft={2}>
2304
- <Text color="red">Error: {model.error_message}</Text>
2305
- </Box>
2306
- )}
2307
3895
  </Box>
2308
3896
  );
2309
3897
  }
@@ -2375,15 +3963,33 @@ function DeployedModelCard({ model, index }: DeployedModelCardProps) {
2375
3963
  interface ProjectModelCardProps {
2376
3964
  model: api.ProjectResponse;
2377
3965
  index: number;
3966
+ jobIndex?: Map<string, api.TrainingJob>;
2378
3967
  }
2379
3968
 
2380
- function ProjectModelCard({ model, index }: ProjectModelCardProps) {
3969
+ function ProjectModelCard({ model, index, jobIndex }: ProjectModelCardProps) {
2381
3970
  const formatDateShort = (dateStr: string | null | undefined) => {
2382
3971
  if (!dateStr) return "N/A";
2383
3972
  const date = new Date(dateStr);
2384
3973
  return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2385
3974
  };
2386
3975
 
3976
+ // Drop "<org>/" prefix from HF model ids for compact list display.
3977
+ const stripOrg = (modelId: string) => {
3978
+ const idx = modelId.indexOf("/");
3979
+ return idx >= 0 ? modelId.slice(idx + 1) : modelId;
3980
+ };
3981
+ const selected = model.selected_model_id ?? "";
3982
+ const activeJob = selected && UUID_REGEX.test(selected) ? jobIndex?.get(selected) : undefined;
3983
+ const selectedDisplay = (() => {
3984
+ if (!selected) return "N/A";
3985
+ if (activeJob) {
3986
+ const name = activeJob.model_name ?? shortJobId(selected);
3987
+ const base = activeJob.base_model ? ` ← ${stripOrg(activeJob.base_model)}` : "";
3988
+ return `${name}${base}`;
3989
+ }
3990
+ return UUID_REGEX.test(selected) ? shortJobId(selected) : stripOrg(selected);
3991
+ })();
3992
+
2387
3993
  return (
2388
3994
  <Box flexDirection="column" marginTop={index > 0 ? 0 : 0}>
2389
3995
  <Box>
@@ -2395,8 +4001,8 @@ function ProjectModelCard({ model, index }: ProjectModelCardProps) {
2395
4001
  <Box width={38}>
2396
4002
  <Text dimColor>{model.id}</Text>
2397
4003
  </Box>
2398
- <Box width={24}>
2399
- <Text color="magenta">{model.selected_model_id || "N/A"}</Text>
4004
+ <Box width={36}>
4005
+ <Text color="magenta" wrap="truncate-end">{selectedDisplay}</Text>
2400
4006
  </Box>
2401
4007
  <Box width={18}>
2402
4008
  <Text dimColor>{formatDateShort(model.created_at)}</Text>
@@ -2425,21 +4031,41 @@ function ModelListCommand({ filter }: ModelListCommandProps) {
2425
4031
  const { exit } = useApp();
2426
4032
  const [state, setState] = useState<"loading" | "done" | "error">("loading");
2427
4033
  const [data, setData] = useState<api.AllModelsResponse | null>(null);
4034
+ const [jobIndex, setJobIndex] = useState<Map<string, api.TrainingJob>>(new Map());
2428
4035
  const [error, setError] = useState("");
2429
4036
 
2430
4037
  useEffect(() => {
2431
4038
  (async () => {
2432
4039
  const result = await api.listAllModels();
2433
- if (result.ok) {
2434
- setData(result.data ?? null);
2435
- setState("done");
2436
- } else {
4040
+ if (!result.ok) {
2437
4041
  setError(result.error ?? "Unknown error");
2438
4042
  setState("error");
4043
+ setTimeout(() => exit(), 500);
4044
+ return;
4045
+ }
4046
+ setData(result.data ?? null);
4047
+
4048
+ // For "registered" (Model Entries), each project's selected_model_id may
4049
+ // be a training-job UUID. Pull all jobs once and build a lookup so we
4050
+ // can render `model_name (base_model)` in place of the bare UUID.
4051
+ const projects = result.data?.projects ?? [];
4052
+ const needsHydration = projects.some((p) =>
4053
+ p.selected_model_id && UUID_REGEX.test(p.selected_model_id),
4054
+ );
4055
+ if (filter === "registered" && needsHydration) {
4056
+ const jobsResult = await api.listJobs();
4057
+ if (jobsResult.ok) {
4058
+ const map = new Map<string, api.TrainingJob>();
4059
+ for (const j of jobsResult.data?.training_jobs ?? []) {
4060
+ if (j.id) map.set(j.id, j);
4061
+ }
4062
+ setJobIndex(map);
4063
+ }
2439
4064
  }
4065
+ setState("done");
2440
4066
  setTimeout(() => exit(), 500);
2441
4067
  })();
2442
- }, [exit]);
4068
+ }, [exit, filter]);
2443
4069
 
2444
4070
  if (state === "loading") {
2445
4071
  return <Loading />;
@@ -2470,15 +4096,20 @@ function ModelListCommand({ filter }: ModelListCommandProps) {
2470
4096
  <Box width={38}>
2471
4097
  <Text bold dimColor>Model ID</Text>
2472
4098
  </Box>
2473
- <Box width={24}>
2474
- <Text bold dimColor>Base Model</Text>
4099
+ <Box width={36}>
4100
+ <Text bold dimColor>Active Model</Text>
2475
4101
  </Box>
2476
4102
  <Box width={18}>
2477
4103
  <Text bold dimColor>Created</Text>
2478
4104
  </Box>
2479
4105
  </Box>
2480
4106
  {data?.projects.map((model, index) => (
2481
- <ProjectModelCard key={model.id || index} model={model} index={index} />
4107
+ <ProjectModelCard
4108
+ key={model.id || index}
4109
+ model={model}
4110
+ index={index}
4111
+ jobIndex={jobIndex}
4112
+ />
2482
4113
  ))}
2483
4114
  </Box>
2484
4115
  )}
@@ -2539,9 +4170,6 @@ function ModelListCommand({ filter }: ModelListCommandProps) {
2539
4170
  <Box width={24}>
2540
4171
  <Text bold dimColor>Dataset</Text>
2541
4172
  </Box>
2542
- <Box width={14}>
2543
- <Text bold dimColor>Config</Text>
2544
- </Box>
2545
4173
  <Box width={30}>
2546
4174
  <Text bold dimColor>Metrics</Text>
2547
4175
  </Box>
@@ -2604,7 +4232,7 @@ function normalizeModelId(modelId: string): string {
2604
4232
 
2605
4233
  function getDecoderTaskType(model: api.BaseModelInfo | null | undefined): string {
2606
4234
  if (!model) return "";
2607
- return `${model.task_type ?? model.type ?? ""}`.trim().toLowerCase();
4235
+ return `${model.task_type ?? ""}`.trim().toLowerCase();
2608
4236
  }
2609
4237
 
2610
4238
  function modelSupportsDecoderInference(model: api.BaseModelInfo | null | undefined): boolean {
@@ -2837,7 +4465,9 @@ type HelpContext =
2837
4465
  | "eval"
2838
4466
  | "benchmark"
2839
4467
  | "inference"
2840
- | "agent";
4468
+ | "agent"
4469
+ | "model-endpoints"
4470
+ | "model-artifacts";
2841
4471
 
2842
4472
  interface HelpProps {
2843
4473
  context?: HelpContext;
@@ -2879,112 +4509,11 @@ const Help: React.FC<HelpProps> = ({ context = "root" }) => {
2879
4509
  <Text dimColor> Dataset format: name[:version] (version defaults to "latest")</Text>
2880
4510
  <Text dimColor> Examples: my-dataset, my-dataset:v1, my-dataset:latest</Text>
2881
4511
  <Text> </Text>
2882
- <Text> dataset list List all datasets</Text>
2883
- <Text> dataset get {"<name[:version]>"} Get dataset details</Text>
2884
- <Text> dataset delete {"<name[:version]>"} Delete a dataset</Text>
2885
- <Text> dataset analyze {"<name[:version]>"} Analyze a dataset</Text>
2886
- <Text> dataset analyze-llm {"<name[:version]>"} LLM-only dataset analysis</Text>
4512
+ <Text> dataset list List all datasets (tabular). Add --json for raw JSON.</Text>
4513
+ <Text> dataset get {"<name[:version]>"} Get dataset details (key/value layout). Add --json for raw JSON.</Text>
2887
4514
  <Text> </Text>
2888
- <Text bold> Generate:</Text>
2889
- <Text> dataset generate ner</Text>
2890
- <Text> --labels {"<l1,l2,...>"} Entity labels (required)</Text>
2891
- <Text> --num {"<n>"} Number of examples (default: 10)</Text>
2892
- <Text> --domain {"<desc>"} Domain description</Text>
2893
- <Text> --save true Save to database</Text>
2894
- <Text> --name {"<name>"} Dataset name (required if --save)</Text>
2895
- <Text> </Text>
2896
- <Text> dataset generate classification</Text>
2897
- <Text> --labels {"<l1,l2,...>"} Class labels (required)</Text>
2898
- <Text> --num {"<n>"} Number of examples (default: 10)</Text>
2899
- <Text> --domain {"<desc>"} Domain description</Text>
2900
- <Text> --multi-label true Enable multi-label</Text>
2901
- <Text> --save true Save to database</Text>
2902
- <Text> --name {"<name>"} Dataset name (required if --save)</Text>
2903
- <Text> </Text>
2904
- <Text> dataset generate custom</Text>
2905
- <Text> --prompt {"<prompt>"} Task description (required)</Text>
2906
- <Text> --format {"<json>"} Output format as JSON (required)</Text>
2907
- <Text> --num {"<n>"} Number of examples (default: 10)</Text>
2908
- <Text> --save true Save to database</Text>
2909
- <Text> --name {"<name>"} Dataset name (required if --save)</Text>
2910
- <Text> </Text>
2911
- <Text> dataset generate decoder</Text>
2912
- <Text> --domain {"<desc>"} Domain/task description (required)</Text>
2913
- <Text> --instruction {"<text>"} System instruction (optional, auto-inferred)</Text>
2914
- <Text> --num {"<n>"} Number of examples (default: 10)</Text>
2915
- <Text> --save true Save to database</Text>
2916
- <Text> --name {"<name>"} Dataset name (required if --save)</Text>
2917
- <Text> Advanced generation flags:</Text>
2918
- <Text> --quality {"<light|medium|heavy>"} Generation quality profile</Text>
2919
- <Text> --generation-profile {"<auto|fast|balanced|quality>"} Runtime profile</Text>
2920
- <Text> --reasoning-trace {"true|false"} Include reasoning traces (decoder only)</Text>
2921
- <Text> --reasoning-effort {"<low|medium|high>"} Reasoning effort</Text>
2922
- <Text> --multiplicator {"<json>"} Multiplicator settings</Text>
2923
- <Text> --use-meta-felix {"true|false"} Use MetaFelix metadata</Text>
2924
- <Text> --min-criteria {"<n>"} Minimum diversity criteria</Text>
2925
- <Text> --target-choices {"<n>"} Diversity target choices</Text>
2926
- <Text> --project-id {"<id>"} Project ID</Text>
2927
- <Text> --type {"training|evaluation|split"} Dataset type</Text>
2928
- <Text> --visibility {"private|public"} Dataset visibility</Text>
2929
- <Text> --split-ratio {"<train:eval>|{json>}"} Split dataset ratio</Text>
2930
- <Text> --negative-ratio {"<n>"} Percent negative samples</Text>
2931
- <Text> --classified-examples {"<json>"} Classified examples with feedback</Text>
2932
- <Text> </Text>
2933
- <Text bold> Infer Labels:</Text>
2934
- <Text> dataset infer ner Infer NER labels from description</Text>
2935
- <Text> dataset infer classification Infer classification labels</Text>
2936
- <Text> dataset infer fields Infer input/output fields</Text>
2937
- <Text> --domain {"<desc>"} Domain description (required)</Text>
2938
- <Text> dataset infer infer-advanced Infer constraints and multiplicator from a prompt</Text>
2939
- <Text> --prompt {"<prompt>"} Prompt for inference</Text>
2940
- <Text> --labels {"<l1,l2,...>"} Optional labels to guide suggestions</Text>
2941
- <Text> --data-type {"<type>"} entity_extraction|classification|json_extraction</Text>
2942
- <Text> dataset infer improve-prompt Improve a generation prompt</Text>
2943
- <Text> --prompt {"<prompt>"} Prompt to improve</Text>
2944
- <Text> --data-type {"<type>"} Optional prompt domain hint</Text>
2945
- <Text> dataset label-existing ner Label existing NER texts</Text>
2946
- <Text> --labels {"<l1,l2,...>"} Labels for entities</Text>
2947
- <Text> --inputs {"[{\"text\":\"...\"},...]"} Input texts JSON array (required)</Text>
2948
- <Text> --name {"<name>"} Output dataset name (optional if --save false)</Text>
2949
- <Text> --project-id {"<project_id>"} Assign output dataset to project</Text>
2950
- <Text> --save {"<true|false>"} Save dataset (default: false)</Text>
2951
- <Text> dataset label-existing classification Label existing classification texts</Text>
2952
- <Text> --labels {"<l1,l2,...>"} Labels for classes</Text>
2953
- <Text> --inputs {"[{\"text\":\"...\"},...]"} Input texts JSON array (required)</Text>
2954
- <Text> --name {"<name>"} Output dataset name (optional if --save false)</Text>
2955
- <Text> --project-id {"<project_id>"} Assign output dataset to project</Text>
2956
- <Text> --save {"<true|false>"} Save dataset (default: false)</Text>
2957
- <Text> dataset label-existing fields Label existing structured records</Text>
2958
- <Text> --input-fields {"[{\"name\":\"...\"},...]"} Input schema fields (required)</Text>
2959
- <Text> --output-fields {"[{\"name\":\"...\"},...]"} Output schema fields (required)</Text>
2960
- <Text> --inputs {"[{\"f1\":\"v\"},...]"} Input records JSON array (required)</Text>
2961
- <Text> --name {"<name>"} Output dataset name (optional if --save false)</Text>
2962
- <Text> --project-id {"<project_id>"} Assign output dataset to project</Text>
2963
- <Text> --save {"<true|false>"} Save dataset (default: false)</Text>
2964
- <Text> </Text>
2965
- <Text bold> Upload/Download:</Text>
2966
- <Text> dataset upload {"<file>"} Upload local file to Pioneer</Text>
2967
- <Text> --name {"<name>"} Dataset name (required)</Text>
2968
- <Text> --type {"<type>"} Type: ner, classification, custom</Text>
2969
- <Text> dataset upload {"<name[:version]>"} --to hf Upload Pioneer dataset to Hugging Face</Text>
2970
- <Text> --repo {"<repo>"} HF repo (required, e.g., username/dataset)</Text>
2971
- <Text> --private Make repo private</Text>
2972
- <Text dimColor> Note: Set HF token with 'pioneer auth hf' first</Text>
2973
- <Text> dataset download {"<name[:version]>"} Download from Pioneer to local file</Text>
2974
- <Text> --format {"<type>"} Format: jsonl, csv, parquet (default: jsonl)</Text>
2975
- <Text> --output {"<path>"} Output file path</Text>
2976
- <Text> dataset download --from hf Download from Hugging Face to Pioneer</Text>
2977
- <Text> --repo {"<repo>"} HF repo (required, e.g., username/dataset)</Text>
2978
- <Text> --name {"<name>"} Local dataset name (optional)</Text>
2979
- <Text> --revision {"<rev>"} Git revision/branch (optional)</Text>
2980
- <Text dimColor> Note: For private repos, set HF token with 'pioneer auth hf'</Text>
2981
- <Text> </Text>
2982
- <Text bold> Data Editing:</Text>
2983
- <Text> dataset edit --help Show data editing commands</Text>
2984
- <Text> dataset edit scan-pii {"<name[:version]>"} Scan for PII</Text>
2985
- <Text> dataset edit dismiss-outlier {"<name[:version]>"} Dismiss an outlier fingerprint</Text>
2986
- <Text> --fingerprint {"<hash>"} Outlier fingerprint from dataset analysis</Text>
2987
- <Text> dataset edit subsample {"<name[:version]>"} Create a subsample</Text>
4515
+ <Text dimColor> Other dataset subcommands (generate/edit/upload/etc.) are temporarily hidden in this version.</Text>
4516
+ <Text dimColor> Use the Pioneer web app at agent.pioneer.ai to create or edit datasets.</Text>
2988
4517
  </Box>
2989
4518
  );
2990
4519
  }
@@ -3047,18 +4576,13 @@ const Help: React.FC<HelpProps> = ({ context = "root" }) => {
3047
4576
  return (
3048
4577
  <Box flexDirection="column">
3049
4578
  <Text bold>Job Commands:</Text>
3050
- <Text> job list List training jobs</Text>
3051
- <Text> job get {"<id>"} Get job details</Text>
4579
+ <Text> job list List training jobs (tabular). Add --json for raw JSON.</Text>
4580
+ <Text> job get {"<id>"} Get job details (key/value layout). Add --json for raw JSON. Accepts a unique short prefix from `job list`.</Text>
3052
4581
  <Text> job logs {"<id>"} Get job logs</Text>
3053
4582
  <Text> job delete {"<id>"} Delete a training job</Text>
3054
- <Text> job create Create training job</Text>
3055
- <Text> --model-name {"<name>"} Model name (required)</Text>
3056
- <Text> --dataset-ids {"<ids>"} Comma-separated datasets (required)</Text>
3057
- <Text> remote:{"<name>"}:{"<version>"} - by name and version</Text>
3058
- <Text> remote:{"<uuid>"} - by UUID</Text>
3059
- <Text> local:{"<name>"} - auto-upload from ./datasets/</Text>
3060
- <Text> --base-model {"<model>"} Base model (default: fastino/gliner2-base-v1)</Text>
3061
- <Text> --epochs {"<n>"} Number of epochs (default: 5)</Text>
4583
+ <Text> </Text>
4584
+ <Text dimColor> To create a training job, use `pioneer agent` — it will help you pick a base model,</Text>
4585
+ <Text dimColor> select datasets, and configure training conversationally.</Text>
3062
4586
  </Box>
3063
4587
  );
3064
4588
  }
@@ -3077,13 +4601,13 @@ const Help: React.FC<HelpProps> = ({ context = "root" }) => {
3077
4601
  <Text> --description {"<text>"} Optional</Text>
3078
4602
  <Text> --model {"<base-model-id>"} Optional (starts interactive picker when omitted)</Text>
3079
4603
  <Text> --example {"<json>"} Optional</Text>
3080
- <Text> model endpoints get {"<model-id>"} Get endpoint/model entry details</Text>
4604
+ <Text> model endpoints get {"<model-id>"} Get endpoint details (key/value layout, includes dataset count). Add --json for raw JSON.</Text>
3081
4605
  <Text> model endpoints update {"<model-id>"} Update endpoint metadata</Text>
3082
4606
  <Text> --name {"<name>"} --icon {"<icon>"} --repo {"<repo-url>"} --description {"<text>"} --model-id {"<id>"}</Text>
3083
4607
  <Text> model endpoints delete {"<model-id>"} Delete an endpoint/model entry</Text>
3084
- <Text> model endpoints dataset-count {"<model-id>"} Get attached dataset count</Text>
3085
- <Text> model endpoints quality-metrics {"<model-id>"} Show LLMAJ pass/fail metrics</Text>
3086
- <Text> model endpoints deploy {"<model-id>"} --job {"<training-job-id>"} [--reason {"<text>"}] Deploy a trained job to the endpoint</Text>
4608
+ <Text> model endpoints quality-metrics {"<model-id>"} Show LLMAJ pass/fail metrics. Add --json for raw JSON.</Text>
4609
+ <Text> model endpoints deploy {"<model-id>"} [--job {"<training-job-id>"}] [--reason {"<text>"}] [--all]</Text>
4610
+ <Text dimColor> If --job is omitted, pick from a list of deployable jobs interactively. By default the list is filtered to jobs whose base model matches the endpoint; pass --all to bypass that filter.</Text>
3087
4611
  <Text> model endpoints rollback {"<model-id>"} {"<deployment-id>"} Rollback endpoint to previous deployment</Text>
3088
4612
  </Box>
3089
4613
  );
@@ -3098,7 +4622,7 @@ const Help: React.FC<HelpProps> = ({ context = "root" }) => {
3098
4622
  <Text> model artifacts list Show both trained and deployed artifacts</Text>
3099
4623
  <Text> model artifacts trained List trained artifacts</Text>
3100
4624
  <Text> model artifacts deployed List deployed artifacts</Text>
3101
- <Text> model artifacts download {"<job-id>"} Download model artifact</Text>
4625
+ <Text> model artifacts download {"<job-id>"} Get a signed download URL for the artifact (key/value layout). Add --json for raw JSON.</Text>
3102
4626
  <Text> model artifacts delete {"<job-id>"} Delete deployed artifact record</Text>
3103
4627
  <Text> model artifacts upload {"<job-id>"} --to hf Upload trained model artifact to Hugging Face</Text>
3104
4628
  <Text> --repo {"<repo>"} HF repo (required, e.g., username/model)</Text>
@@ -3116,17 +4640,20 @@ const Help: React.FC<HelpProps> = ({ context = "root" }) => {
3116
4640
  <Text bold>Model Commands:</Text>
3117
4641
  <Text> model endpoints ... (alias: model_endpoints) Manage model catalog entries (from /projects)</Text>
3118
4642
  <Text> model artifacts ... (alias: model_artifacts) Manage trained/deployed artifacts (from /felix)</Text>
4643
+ <Text> model base-models List available base models (tabular)</Text>
3119
4644
  <Text> </Text>
3120
4645
  <Text> model endpoints list</Text>
3121
4646
  <Text> model endpoints create</Text>
3122
4647
  <Text> model endpoints get {"<model-id>"}</Text>
3123
- <Text> model endpoints deploy {"<model-id>"} --job {"<training-job-id>"} [--reason {"<text>"}]</Text>
4648
+ <Text> model endpoints quality-metrics {"<model-id>"}</Text>
4649
+ <Text> model endpoints deploy {"<model-id>"} [--job {"<training-job-id>"}] [--reason {"<text>"}] [--all]</Text>
3124
4650
  <Text> model endpoints rollback {"<model-id>"} {"<deployment-id>"}</Text>
3125
4651
  <Text> model artifacts list</Text>
3126
4652
  <Text> model artifacts trained</Text>
3127
- <Text> model artifacts deployed</Text>
3128
- <Text> model artifacts download {"<job-id>"}</Text>
4653
+ <Text> model artifacts deployed</Text>
4654
+ <Text> model artifacts download {"<job-id>"}</Text>
3129
4655
  <Text> </Text>
4656
+ <Text dimColor> Most read commands accept `--json` for the raw JSON payload.</Text>
3130
4657
  </Box>
3131
4658
  );
3132
4659
  }
@@ -3188,6 +4715,8 @@ const Help: React.FC<HelpProps> = ({ context = "root" }) => {
3188
4715
  <Text> --mode {"<research>"} --mode research uses Pro workflow</Text>
3189
4716
  <Text> </Text>
3190
4717
  <Text> Omit --mode to use the default standard interactive mode.</Text>
4718
+ <Text> agent sessions List and resume previous sessions</Text>
4719
+ <Text> agent resume {"[conversation-id]"} List sessions, then resume a selected conversation</Text>
3191
4720
  <Text> --conversation-id {"<id>"} Continue an existing conversation</Text>
3192
4721
  <Text> --filters {"<json>"} Reserved for future query filters</Text>
3193
4722
  <Text> --history {"<json>"} Optional message history JSON</Text>
@@ -3196,6 +4725,8 @@ const Help: React.FC<HelpProps> = ({ context = "root" }) => {
3196
4725
  <Text dimColor>Example: pioneer agent --mode research</Text>
3197
4726
  <Text dimColor>Then type: Analyze failures and propose retraining plan</Text>
3198
4727
  <Text dimColor>Then type: Draft a short status summary</Text>
4728
+ <Text dimColor>Example: pioneer agent resume</Text>
4729
+ <Text dimColor>Example: pioneer agent resume b042f7a1-0e7e-4f78-96df-a1cc2d4afcdf</Text>
3199
4730
  </Box>
3200
4731
  );
3201
4732
  }
@@ -3254,13 +4785,8 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
3254
4785
  rest[0] === "create" &&
3255
4786
  parseErrors.length === 1 &&
3256
4787
  parseErrors[0] === "--model";
3257
- const isModelEndpointsDeployMissingJob =
3258
- group === "model" &&
3259
- normalizedAction === "endpoints" &&
3260
- rest[0] === "deploy" &&
3261
- !flags["job"];
3262
4788
 
3263
- if (group === "dataset" || group === "inference" || group === "eval" || group === "benchmark") {
4789
+ if (group === "inference" || group === "eval" || group === "benchmark") {
3264
4790
  return (
3265
4791
  <ErrorMessage
3266
4792
  error={`The '${group}' command group is temporarily hidden for this version. Use 'pioneer --help' to see available commands.`}
@@ -3268,35 +4794,64 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
3268
4794
  );
3269
4795
  }
3270
4796
 
3271
- if (hasParseErrors && !isModelCreateMissingModel) {
3272
- if (isModelEndpointsDeployMissingJob) {
3273
- const errorMessage = rest[1]
3274
- ? `Training job ID required: model endpoints deploy ${rest[1]} --job <training-job-id>`
3275
- : "Training job ID required: model endpoints deploy <model-id> --job <training-job-id>";
3276
-
4797
+ if (group === "dataset") {
4798
+ const allowedDatasetActions = new Set(["list", "get", "help", undefined, ""]);
4799
+ const subAction = action ?? "";
4800
+ const isHelp = flags["help"] === "true" || subAction === "" || subAction === "help";
4801
+ if (!isHelp && !allowedDatasetActions.has(subAction)) {
3277
4802
  return (
3278
- <ApiCommand
3279
- action={() =>
3280
- Promise.resolve<api.ApiResult<{ message: string }>>({
3281
- ok: false,
3282
- status: 400,
3283
- error: errorMessage,
3284
- data: {
3285
- message: errorMessage,
3286
- },
3287
- })
3288
- }
3289
- successMessage="Validation failed"
4803
+ <ErrorMessage
4804
+ error={`'dataset ${subAction}' is temporarily hidden for this version. Available: pioneer dataset list, pioneer dataset get <name[:version]>.`}
3290
4805
  />
3291
4806
  );
3292
4807
  }
4808
+ }
4809
+
4810
+ if (hasParseErrors && !isModelCreateMissingModel) {
4811
+ const missingValueHints: Record<string, string> = {
4812
+ "--model": "<base-model-id>",
4813
+ "--mode":
4814
+ group === "agent" || group === "agents"
4815
+ ? "<research>"
4816
+ : "<value>",
4817
+ "--conversation-id": "<session-id>",
4818
+ "--conversation": "<session-id>",
4819
+ "--history": "<json>",
4820
+ "--filters": "<json>",
4821
+ "--format": "<format>",
4822
+ "--text": "<text>",
4823
+ "--prompt": "<text>",
4824
+ "--name": "<name>",
4825
+ "--repo": "<url>",
4826
+ "--icon": "<icon>",
4827
+ "--description": "<text>",
4828
+ "--api-key": "<key>",
4829
+ "--api-url": "<url>",
4830
+ "--message": "<text>",
4831
+ "--inputs": "<json>",
4832
+ "--labels": "<json-array>",
4833
+ "--label-column": "<column>",
4834
+ "--text-column": "<column>",
4835
+ "--output": "<path>",
4836
+ "--format-results": "<true|false>",
4837
+ "--include-confidence": "<true|false>",
4838
+ "--include-spans": "<true|false>",
4839
+ "--reasoning-trace": "<true|false>",
4840
+ "--reasoning-effort": "<low|medium|high>",
4841
+ };
4842
+ const getValueHint = (flag: string) => {
4843
+ if (flag === "--mode" && group === "agent") {
4844
+ return "<research> (default is standard when omitted)";
4845
+ }
4846
+ return missingValueHints[flag] ?? "<value>";
4847
+ };
3293
4848
 
3294
4849
  return (
3295
4850
  <Box flexDirection="column">
3296
4851
  <ErrorMessage error="One or more flags are missing values. Please provide values for: " />
3297
4852
  {parseErrors.map((flag) => (
3298
4853
  <Text dimColor key={flag}>
3299
- - {flag} {"<value>"}
4854
+ - {flag} {getValueHint(flag)}
3300
4855
  </Text>
3301
4856
  ))}
3302
4857
  </Box>
@@ -3654,6 +5209,9 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
3654
5209
  }
3655
5210
 
3656
5211
  if (action === "list") {
5212
+ if (flags.json === "true") {
5213
+ return <ApiCommand action={api.listDatasets} />;
5214
+ }
3657
5215
  return <DatasetListCommand />;
3658
5216
  }
3659
5217
  if (action === "get" && rest[0]) {
@@ -3661,7 +5219,10 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
3661
5219
  if (!dataset) {
3662
5220
  return <ErrorMessage error={`Invalid dataset format: ${rest[0]}. Use name[:version] format (e.g., my-dataset or my-dataset:v1).`} />;
3663
5221
  }
3664
- return <ApiCommand action={() => api.getDataset(dataset)} />;
5222
+ if (flags.json === "true") {
5223
+ return <ApiCommand action={() => api.getDataset(dataset)} />;
5224
+ }
5225
+ return <DatasetGetCommand dataset={dataset} />;
3665
5226
  }
3666
5227
  if (action === "delete" && rest[0]) {
3667
5228
  const dataset = parseDatasetRef(rest[0]);
@@ -4178,48 +5739,55 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4178
5739
  return <Help context="job" />;
4179
5740
  }
4180
5741
  if (action === "list") {
4181
- return <ApiCommand action={api.listJobs} />;
5742
+ if (flags.json === "true") {
5743
+ return <ApiCommand action={api.listJobs} />;
5744
+ }
5745
+ return <JobListCommand />;
4182
5746
  }
4183
5747
  if (action === "get" && rest[0]) {
4184
- return <ApiCommand action={() => api.getJob(rest[0])} />;
5748
+ const wantsJson = flags.json === "true";
5749
+ return (
5750
+ <WithResolvedJobId
5751
+ rawId={rest[0]}
5752
+ render={(jobId) =>
5753
+ wantsJson ? (
5754
+ <ApiCommand action={() => api.getJob(jobId)} />
5755
+ ) : (
5756
+ <JobGetCommand jobId={jobId} />
5757
+ )
5758
+ }
5759
+ />
5760
+ );
4185
5761
  }
4186
5762
  if (action === "logs" && rest[0]) {
4187
- return <JobLogsCommand jobId={rest[0]} />;
5763
+ return (
5764
+ <WithResolvedJobId
5765
+ rawId={rest[0]}
5766
+ render={(jobId) => <JobLogsCommand jobId={jobId} />}
5767
+ />
5768
+ );
4188
5769
  }
4189
5770
  if (action === "delete" && rest[0]) {
4190
5771
  return (
4191
- <ApiCommand
4192
- action={() => api.deleteJob(rest[0])}
4193
- successMessage={`Training job ${rest[0]} deleted`}
5772
+ <WithResolvedJobId
5773
+ rawId={rest[0]}
5774
+ render={(jobId) => (
5775
+ <ApiCommand
5776
+ action={() => api.deleteJob(jobId)}
5777
+ successMessage={`Training job ${jobId} deleted`}
5778
+ />
5779
+ )}
4194
5780
  />
4195
5781
  );
4196
5782
  }
4197
5783
  if (action === "create") {
4198
- const modelName = flags["model-name"];
4199
- const datasetStrings = flags["dataset-ids"]?.split(",").filter(Boolean) ?? [];
4200
- const baseModel = flags["base-model"];
4201
- const epochs = flags["epochs"] ? parseInt(flags["epochs"], 10) : undefined;
4202
-
4203
- if (!modelName || datasetStrings.length === 0) {
4204
- return <ErrorMessage error="--model-name and --dataset-ids are required" />;
4205
- }
4206
-
4207
- // Parse dataset strings with explicit local:/remote: prefixes
4208
- const datasets: ParsedDataset[] = [];
4209
- for (const ds of datasetStrings) {
4210
- const parsed = parseDatasetString(ds);
4211
- if ("error" in parsed) {
4212
- return <ErrorMessage error={parsed.error} />;
4213
- }
4214
- datasets.push(parsed);
4215
- }
4216
-
4217
5784
  return (
4218
- <JobCreateCommand
4219
- modelName={modelName}
4220
- datasets={datasets}
4221
- baseModel={baseModel}
4222
- epochs={epochs}
5785
+ <ErrorMessage
5786
+ error={
5787
+ "`pioneer job create` has been removed.\n" +
5788
+ "Use `pioneer agent` to create training jobs conversationally — it will help you pick a base model,\n" +
5789
+ "select datasets, and configure training without needing to remember flags or dataset IDs."
5790
+ }
4223
5791
  />
4224
5792
  );
4225
5793
  }
@@ -4233,7 +5801,10 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4233
5801
  }
4234
5802
 
4235
5803
  if (action === "base-models" || action === "models" || action === "list") {
4236
- return <ApiCommand action={api.listBaseModels} />;
5804
+ if (flags.json === "true") {
5805
+ return <ApiCommand action={api.listBaseModels} />;
5806
+ }
5807
+ return <BaseModelsListCommand />;
4237
5808
  }
4238
5809
 
4239
5810
  if (action === "encoder") {
@@ -4431,6 +6002,13 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4431
6002
  return <Help context="model" />;
4432
6003
  }
4433
6004
 
6005
+ if (normalizedAction === "base-models" || normalizedAction === "models") {
6006
+ if (flags.json === "true") {
6007
+ return <ApiCommand action={api.listBaseModels} />;
6008
+ }
6009
+ return <BaseModelsListCommand />;
6010
+ }
6011
+
4434
6012
  if (normalizedAction === "endpoints") {
4435
6013
  const endpointAction = rest[0];
4436
6014
  const endpointArgs = rest.slice(1);
@@ -4507,7 +6085,10 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4507
6085
  if (!modelId) {
4508
6086
  return <ErrorMessage error="Model ID required: model endpoints get <model-id>" />;
4509
6087
  }
4510
- return <ApiCommand action={() => api.getProject(modelId)} />;
6088
+ if (flags.json === "true") {
6089
+ return <ApiCommand action={() => api.getProject(modelId)} />;
6090
+ }
6091
+ return <ModelEndpointGetCommand modelId={modelId} />;
4511
6092
  }
4512
6093
 
4513
6094
  if (endpointAction === "update") {
@@ -4558,11 +6139,14 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4558
6139
  }
4559
6140
 
4560
6141
  if (endpointAction === "dataset-count" || endpointAction === "count") {
4561
- const modelId = endpointArgs[0];
4562
- if (!modelId) {
4563
- return <ErrorMessage error="Model ID required: model endpoints dataset-count <model-id>" />;
4564
- }
4565
- return <ApiCommand action={() => api.getProjectDatasetCount(modelId)} />;
6142
+ return (
6143
+ <ErrorMessage
6144
+ error={
6145
+ "`pioneer model endpoints dataset-count` has been removed.\n" +
6146
+ "The dataset count and `can_delete` flag are now surfaced inline in `pioneer model endpoints get <model-id>`."
6147
+ }
6148
+ />
6149
+ );
4566
6150
  }
4567
6151
 
4568
6152
  if (endpointAction === "quality-metrics" || endpointAction === "quality") {
@@ -4570,40 +6154,75 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4570
6154
  if (!modelId) {
4571
6155
  return <ErrorMessage error="Model ID required: model endpoints quality-metrics <model-id>" />;
4572
6156
  }
4573
- return <ApiCommand action={() => api.getProjectQualityMetrics(modelId)} />;
6157
+ if (flags.json === "true") {
6158
+ return <ApiCommand action={() => api.getProjectQualityMetrics(modelId)} />;
6159
+ }
6160
+ return <ModelQualityMetricsCommand modelId={modelId} />;
4574
6161
  }
4575
6162
 
4576
6163
  if (endpointAction === "deploy") {
4577
6164
  const modelId = endpointArgs[0];
4578
- const jobId = flags["job"];
6165
+ const rawJobId = flags["job"];
4579
6166
 
4580
6167
  if (!modelId) {
4581
- return <ErrorMessage error="Model ID required: model endpoints deploy <model-id> --job <training-job-id>" />;
4582
- }
4583
- if (!jobId) {
4584
- return <ErrorMessage error="Training job ID required: model endpoints deploy <model-id> --job <training-job-id>" />;
4585
- }
4586
- if (jobId.length !== 36) {
4587
- return (
4588
- <Box flexDirection="column">
4589
- <ErrorMessage error="Invalid job ID: must be full UUID (36 characters)" />
4590
- <Text dimColor> Provided: {jobId} ({jobId.length} characters)</Text>
4591
- <Text dimColor> Tip: Use 'pioneer model artifacts list' and 'pioneer model artifacts trained' to see full job IDs</Text>
4592
- </Box>
4593
- );
6168
+ return <ErrorMessage error="Model ID required: model endpoints deploy <model-id> [--job <training-job-id>]" />;
4594
6169
  }
4595
6170
 
4596
6171
  const reason = flags["reason"];
4597
6172
 
6173
+ if (!rawJobId) {
6174
+ if (!isRawModeSupported) {
6175
+ return (
6176
+ <Box flexDirection="column">
6177
+ <ErrorMessage error="Interactive job picker requires a TTY." />
6178
+ <Text>List trained jobs with `pioneer job list` and pass an explicit ID:</Text>
6179
+ <Text dimColor> pioneer model endpoints deploy {modelId} --job &lt;training-job-id&gt;</Text>
6180
+ </Box>
6181
+ );
6182
+ }
6183
+ const showAll = flags["all"] === "true";
6184
+ return <DeployJobPickerCommand modelId={modelId} reason={reason} showAll={showAll} />;
6185
+ }
6186
+
4598
6187
  return (
4599
- <ApiCommand
4600
- action={() =>
4601
- api.deployTrainingJobToProject(modelId, {
4602
- training_job_id: jobId,
4603
- ...(reason ? { reason } : {}),
4604
- })
4605
- }
4606
- successMessage={`Deployment initiated for project ${modelId} from job ${jobId}`}
6188
+ <WithResolvedJobId
6189
+ rawId={rawJobId}
6190
+ render={(jobId) => (
6191
+ <ApiCommand
6192
+ action={async () => {
6193
+ // Deploy requires the job's project_id to match the target.
6194
+ // PATCH first to link them; the backend treats a no-op assign
6195
+ // as success. While we have the job around, capture its
6196
+ // base_model so we can enrich the deployment record (which
6197
+ // currently returns base_model=null for training-job deploys).
6198
+ const jobInfo = await api.getJob(jobId);
6199
+ const patch = await api.updateTrainingJob(jobId, {
6200
+ project_id: modelId,
6201
+ });
6202
+ if (!patch.ok) {
6203
+ return patch as api.ApiResult<unknown>;
6204
+ }
6205
+ const result = await api.deployTrainingJobToProject(modelId, {
6206
+ training_job_id: jobId,
6207
+ ...(reason ? { reason } : {}),
6208
+ });
6209
+ if (
6210
+ result.ok &&
6211
+ result.data &&
6212
+ jobInfo.ok &&
6213
+ jobInfo.data?.base_model &&
6214
+ !result.data.base_model
6215
+ ) {
6216
+ return {
6217
+ ...result,
6218
+ data: { ...result.data, base_model: jobInfo.data.base_model },
6219
+ };
6220
+ }
6221
+ return result;
6222
+ }}
6223
+ successMessage={`Deployment initiated for project ${modelId} from job ${jobId}`}
6224
+ />
6225
+ )}
4607
6226
  />
4608
6227
  );
4609
6228
  }
@@ -4664,37 +6283,33 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4664
6283
  if (!artifactArgs[0]) {
4665
6284
  return <ErrorMessage error="Job ID required: model artifacts download <job-id>" />;
4666
6285
  }
4667
- const jobId = artifactArgs[0];
4668
- if (jobId.length !== 36) {
4669
- return (
4670
- <Box flexDirection="column">
4671
- <ErrorMessage error="Invalid job ID: must be full UUID (36 characters)" />
4672
- <Text dimColor> Provided: {jobId} ({jobId.length} characters)</Text>
4673
- <Text dimColor> Tip: Use 'pioneer model artifacts trained' or 'pioneer model artifacts deployed' to see full job IDs</Text>
4674
- </Box>
4675
- );
4676
- }
4677
- return <ApiCommand action={() => api.downloadModel(jobId)} />;
6286
+ return (
6287
+ <WithResolvedJobId
6288
+ rawId={artifactArgs[0]}
6289
+ render={(jobId) =>
6290
+ flags.json === "true" ? (
6291
+ <ApiCommand action={() => api.downloadModel(jobId)} />
6292
+ ) : (
6293
+ <ModelArtifactDownloadCommand jobId={jobId} />
6294
+ )
6295
+ }
6296
+ />
6297
+ );
4678
6298
  }
4679
6299
 
4680
6300
  if (artifactsAction === "delete") {
4681
6301
  if (!artifactArgs[0]) {
4682
6302
  return <ErrorMessage error="Model ID required: model artifacts delete <job-id>" />;
4683
6303
  }
4684
- const jobId = artifactArgs[0];
4685
- if (jobId.length !== 36) {
4686
- return (
4687
- <Box flexDirection="column">
4688
- <ErrorMessage error="Invalid job ID: must be full UUID (36 characters)" />
4689
- <Text dimColor> Provided: {jobId} ({jobId.length} characters)</Text>
4690
- <Text dimColor> Tip: Use 'pioneer model artifacts list' to see full job IDs</Text>
4691
- </Box>
4692
- );
4693
- }
4694
6304
  return (
4695
- <ApiCommand
4696
- action={() => api.deleteModel(jobId)}
4697
- successMessage={`Model ${jobId} deleted`}
6305
+ <WithResolvedJobId
6306
+ rawId={artifactArgs[0]}
6307
+ render={(jobId) => (
6308
+ <ApiCommand
6309
+ action={() => api.deleteModel(jobId)}
6310
+ successMessage={`Model ${jobId} deleted`}
6311
+ />
6312
+ )}
4698
6313
  />
4699
6314
  );
4700
6315
  }
@@ -4720,7 +6335,7 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4720
6335
  if (!artifactArgs[0]) {
4721
6336
  return <ErrorMessage error="Job ID required: model artifacts upload <job-id> --to hf --repo <repo>" />;
4722
6337
  }
4723
- const jobId = artifactArgs[0];
6338
+ const rawJobId = artifactArgs[0];
4724
6339
  const repo = flags["repo"];
4725
6340
  const hfTokenFlag = flags["hf-token"];
4726
6341
  const isPrivate = flags["private"]?.toLowerCase() === "true";
@@ -4744,15 +6359,20 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4744
6359
  }
4745
6360
 
4746
6361
  return (
4747
- <ApiCommand
4748
- action={() =>
4749
- api.pushModelToHub(jobId, {
4750
- hf_token: hfToken,
4751
- repo_id: repo,
4752
- private: isPrivate,
4753
- })
4754
- }
4755
- successMessage={`Model uploaded to Hugging Face: ${repo}`}
6362
+ <WithResolvedJobId
6363
+ rawId={rawJobId}
6364
+ render={(jobId) => (
6365
+ <ApiCommand
6366
+ action={() =>
6367
+ api.pushModelToHub(jobId, {
6368
+ hf_token: hfToken,
6369
+ repo_id: repo,
6370
+ private: isPrivate,
6371
+ })
6372
+ }
6373
+ successMessage={`Model uploaded to Hugging Face: ${repo}`}
6374
+ />
6375
+ )}
4756
6376
  />
4757
6377
  );
4758
6378
  }
@@ -4951,13 +6571,15 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4951
6571
  return <Help context="agent" />;
4952
6572
  }
4953
6573
 
4954
- if (action && !action.startsWith("-")) {
6574
+ if (action && action !== "resume" && action !== "sessions" && !action.startsWith("-")) {
4955
6575
  return (
4956
6576
  <ErrorMessage
4957
6577
  error={
4958
6578
  'Invalid agent command syntax. Use one of:\n' +
4959
6579
  "pioneer agent\n" +
4960
- "pioneer agent --mode research"
6580
+ "pioneer agent --mode research\n" +
6581
+ "pioneer agent sessions\n" +
6582
+ "pioneer agent resume [conversation-id]"
4961
6583
  }
4962
6584
  />
4963
6585
  );
@@ -4992,11 +6614,34 @@ const App: React.FC<AppProps> = ({ command, flags, parseErrors }) => {
4992
6614
  return <ErrorMessage error='--filters is not supported for /auto-agent/clarify. Omit this flag for now.' />;
4993
6615
  }
4994
6616
 
6617
+ if (action === "resume" || action === "sessions") {
6618
+ if (!isRawModeSupported) {
6619
+ return (
6620
+ <ErrorMessage
6621
+ error="Interactive input is not supported in this terminal.\nUse interactive mode for this environment: agent --help"
6622
+ />
6623
+ );
6624
+ }
6625
+ if (rest[0] || flags["conversation-id"]) {
6626
+ const resumeId = rest[0] ?? flags["conversation-id"];
6627
+ return (
6628
+ <AutoAgentInteractiveSession
6629
+ conversationId={resumeId}
6630
+ history={history}
6631
+ mode={mode}
6632
+ allowSessionCreation={false}
6633
+ />
6634
+ );
6635
+ }
6636
+ return <AgentResumeCommand mode={mode} />;
6637
+ }
6638
+
4995
6639
  return (
4996
6640
  <AgentInteractivePrompt
4997
6641
  conversationId={flags["conversation-id"]}
4998
6642
  history={history}
4999
6643
  mode={mode}
6644
+ allowSessionCreation={true}
5000
6645
  />
5001
6646
  );
5002
6647
  }
@@ -5027,4 +6672,6 @@ async function main() {
5027
6672
  await render(<App command={command} flags={flags} parseErrors={parseErrors} />).waitUntilExit();
5028
6673
  }
5029
6674
 
5030
- main();
6675
+ if (process.env.PIONEER_SKIP_AUTORUN !== "true") {
6676
+ main();
6677
+ }