base_parts_ai 1.0.59 → 1.0.61

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/README.md CHANGED
@@ -38,7 +38,7 @@ ucx
38
38
  jgrok
39
39
  ```
40
40
 
41
- `ucx` 会扫描用户主目录下一层的 `~/.codex` 与 `~/.codex_*`。账号名由 `config.toml` 顶层 `model_provider`、`auth.json` 中非空 `OPENAI_API_KEY` 的脱敏短名、JWT email 组合成 `配置名_Key_Email`,缺段去掉;不会自动创建空的 `~/.codex`。选中当前 `~/.codex` 会进入二级菜单,只在该目录内用 `juser_*` 切换 `config.toml`、`auth.json`、`.env`、`models.json`,不改其它 `~/.codex_*` 账号目录。二级选中子账户后(含选中当前无需切换)会进入下一级选择:还原最后一次会话并进入 codex(读 `~/.codex/session_index.jsonl` 取最后一次会话 id 后执行 `codex fork <会话id> --yolo`)、直接进入 codex(`codex --yolo`)、退出 ucx;启动 codex 时会等待其执行完成后一起退出并透传其退出码,`~/.codex/.env` 存在时会注入子进程环境。
41
+ `ucx` 会扫描用户主目录下一层的 `~/.codex` 与 `~/.codex_*`。账号名由 `config.toml` 顶层 `model_provider`、`auth.json` 中非空 `OPENAI_API_KEY` 的脱敏短名、JWT email 组合成 `配置名_Key_Email`,缺段去掉;不会自动创建空的 `~/.codex`。选中当前 `~/.codex` 会进入二级菜单,只在该目录内用 `juser_*` 切换 `config.toml`、`auth.json`、`.env`、`models.json`,不改其它 `~/.codex_*` 账号目录。二级选中子账户后(含选中当前无需切换)会进入下一级选择:选择历史会话并进入 codex(读 `~/.codex/session_index.jsonl` 倒序去重列出最近 8 条会话,id 在前、名字在后,最新在上并默认选中,选择后再执行 `codex fork <会话id> --yolo`)、直接进入 codex(`codex --yolo`)、退出 ucx;启动 codex 时会等待其执行完成后一起退出并透传其退出码,`~/.codex/.env` 存在时会注入子进程环境。
42
42
 
43
43
  `jgrok` 若 `~/.grok/.env` 不存在则写入代理示例并打印绝对路径,请编辑后再运行。已存在则把该文件注入环境变量:本机没有 `grok` 时通过腾讯云 npm 源安装 `@xai-official/grok`(安装不注入环境变量),然后启动 `grok`,用户参数原样透传。
44
44
 
package/bin/jgrok.js CHANGED
@@ -32,7 +32,7 @@ function loadLib(libName) {
32
32
  */
33
33
  async function runCli(args) {
34
34
  var buildCfg = getBuildCfg();
35
- return await loadLib('jgrok')(args || [], buildCfg);
35
+ return loadLib('jgrok')(args || [], buildCfg);
36
36
  }
37
37
 
38
38
  if (require.main === module) {
package/lib/jgrok.js CHANGED
@@ -141,27 +141,54 @@ function buildChildEnv(content) {
141
141
  }
142
142
 
143
143
  /**
144
- * 启动官方 grok,Windows 走 shell 以便找到 grok.cmd
144
+ * 前台启动官方 grok 并等待其执行完成,返回其退出码;spawn 失败向上抛错
145
+ * 与 ucx 的 launchCodex 相同的健壮启动模式:异步 spawn + stdio:'inherit' 等待 close 事件。
146
+ * 当前 jgrok 没有 inquirer 交互不会抢输入,但启动前仍恢复 cooked 模式、真实启动后立即
147
+ * 销毁父进程 stdin,防止将来增加交互业务后出现“grok 界面起来但无法输入”的问题
148
+ * (inquirer 用过 stdin 后父进程挂起的读取会抢走子进程键盘输入)。
145
149
  * @param {Array<string>} args 透传给 grok 的参数
146
150
  * @param {object} childEnv 注入后的环境变量
147
- * @param {object} [options] 可选配置,spawnImpl 用于注入 spawnSync
148
- * @returns {number} grok 退出码
151
+ * @param {object} [options] 可选配置,spawnImpl 用于注入 spawn
152
+ * @returns {Promise<number>} grok 退出码
149
153
  */
150
- function spawnGrok(args, childEnv, options) {
154
+ async function spawnGrok(args, childEnv, options) {
151
155
  options = options || {};
152
- var spawn = options.spawnImpl || childProcess.spawnSync;
153
- var result = spawn('grok', args || [], {
156
+ // 恢复 cooked 模式,给子进程一个干净的终端基线(将来加入 inquirer 交互后尤为关键)
157
+ if (process.stdin && process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
158
+ try {
159
+ process.stdin.setRawMode(false);
160
+ } catch (e) {
161
+ // 模式恢复失败不阻断启动
162
+ }
163
+ }
164
+ var spawn = options.spawnImpl || childProcess.spawn;
165
+ var child = spawn('grok', args || [], {
154
166
  stdio: 'inherit',
155
167
  shell: process.platform === 'win32',
156
168
  env: childEnv,
157
- }) || {};
158
- if (result.error) {
159
- throw result.error;
160
- }
161
- if (result.signal) {
162
- return 1;
169
+ });
170
+ // 真实启动时销毁父进程 stdin,取消可能挂起的读取,把键盘输入完全让给 grok;
171
+ // 句柄已在 spawn 时复制给子进程,销毁父进程 stdin 不影响 grok。
172
+ // 注入 spawnImpl 的单测不销毁,避免破坏测试进程自身的 stdin。
173
+ if (!options.spawnImpl && process.stdin && typeof process.stdin.destroy === 'function') {
174
+ if (typeof process.stdin.pause === 'function') {
175
+ process.stdin.pause();
176
+ }
177
+ process.stdin.destroy();
163
178
  }
164
- return typeof result.status === 'number' ? result.status : 0;
179
+ var exitCode = await new Promise(function (resolve, reject) {
180
+ child.on('error', function (err) {
181
+ reject(err);
182
+ });
183
+ child.on('close', function (code, signal) {
184
+ if (signal) {
185
+ resolve(1);
186
+ return;
187
+ }
188
+ resolve(typeof code === 'number' ? code : 0);
189
+ });
190
+ });
191
+ return exitCode;
165
192
  }
166
193
 
167
194
  /**
package/lib/ucx.js CHANGED
@@ -27,7 +27,7 @@ var REMOVE_CURRENT_SUB = '__remove_current_sub__';
27
27
  /** 二级菜单:返回一级账号列表 */
28
28
  var BACK_TO_PARENT = '__back_to_parent__';
29
29
 
30
- /** 三级菜单:还原最后一次会话并进入 codex */
30
+ /** 三级菜单:选择历史会话并进入 codex */
31
31
  var POST_SWITCH_RESUME = '__post_switch_resume__';
32
32
 
33
33
  /** 三级菜单:直接进入 codex */
@@ -36,6 +36,12 @@ var POST_SWITCH_DIRECT = '__post_switch_direct__';
36
36
  /** 三级菜单:退出 ucx,不执行任何命令 */
37
37
  var POST_SWITCH_EXIT = '__post_switch_exit__';
38
38
 
39
+ /** 会话列表:返回三级菜单,不执行 fork */
40
+ var POST_SWITCH_RESUME_BACK = '__post_switch_resume_back__';
41
+
42
+ /** 还原会话时最多列出的最近有效会话数 */
43
+ var RESUME_SESSION_LIMIT = 8;
44
+
39
45
  /** 子账户目录前缀,只出现在当前 ~/.codex 内 */
40
46
  var SUB_DIR_PREFIX = 'juser_';
41
47
 
@@ -479,19 +485,23 @@ function codexCommandExists(execImpl) {
479
485
  }
480
486
 
481
487
  /**
482
- * 读取 ~/.codex/session_index.jsonl 的最后一次会话记录:倒序跳过坏行,取第一条含非空 id 的记录
488
+ * 读取 ~/.codex/session_index.jsonl 最近若干条有效会话:倒序跳过坏行,按 id 去重
483
489
  * @param {string} homeDir 用户主目录
484
- * @returns {{ id: string, threadName: string }|null} 无有效记录时返回 null
490
+ * @param {number} [limit] 最多返回条数,默认 RESUME_SESSION_LIMIT
491
+ * @returns {Array<{ id: string, threadName: string }>} 新到旧;无有效记录时为空数组
485
492
  */
486
- function readLastSessionRecord(homeDir) {
493
+ function readRecentSessionRecords(homeDir, limit) {
494
+ var max = typeof limit === 'number' && limit > 0 ? limit : RESUME_SESSION_LIMIT;
487
495
  var indexPath = path.join(homeDir, '.codex', 'session_index.jsonl');
488
496
  var content = '';
489
497
  try {
490
498
  content = fs.readFileSync(indexPath, 'utf8');
491
499
  } catch (e) {
492
- return null;
500
+ return [];
493
501
  }
494
502
  var lines = content.split(/\r?\n/);
503
+ var seen = {};
504
+ var records = [];
495
505
  for (var i = lines.length - 1; i >= 0; i -= 1) {
496
506
  var line = lines[i].trim();
497
507
  if (!line) {
@@ -500,16 +510,48 @@ function readLastSessionRecord(homeDir) {
500
510
  try {
501
511
  var record = JSON.parse(line);
502
512
  if (record && typeof record.id === 'string' && record.id.trim()) {
503
- return {
504
- id: record.id.trim(),
513
+ var id = record.id.trim();
514
+ if (seen[id]) {
515
+ continue;
516
+ }
517
+ seen[id] = true;
518
+ records.push({
519
+ id: id,
505
520
  threadName: typeof record.thread_name === 'string' ? record.thread_name : '',
506
- };
521
+ });
522
+ if (records.length >= max) {
523
+ break;
524
+ }
507
525
  }
508
526
  } catch (e) {
509
527
  // 半行写入等坏行跳过,继续向前找
510
528
  }
511
529
  }
512
- return null;
530
+ return records;
531
+ }
532
+
533
+ /**
534
+ * 读取 ~/.codex/session_index.jsonl 的最后一次会话记录
535
+ * @param {string} homeDir 用户主目录
536
+ * @returns {{ id: string, threadName: string }|null} 无有效记录时返回 null
537
+ */
538
+ function readLastSessionRecord(homeDir) {
539
+ var records = readRecentSessionRecords(homeDir, 1);
540
+ return records.length ? records[0] : null;
541
+ }
542
+
543
+ /**
544
+ * 会话选项展示名:有 thread_name 时为「id 名字」,否则只用 id
545
+ * @param {{ id: string, threadName: string }} session 会话记录
546
+ * @returns {string}
547
+ */
548
+ function formatSessionChoiceName(session) {
549
+ var name = session && typeof session.threadName === 'string' ? session.threadName.trim() : '';
550
+ var id = session && session.id ? session.id : '';
551
+ if (name) {
552
+ return id + ' ' + name;
553
+ }
554
+ return id;
513
555
  }
514
556
 
515
557
  /**
@@ -1025,25 +1067,41 @@ async function removeCurrentSubAccount(codexDir, currentRecord, options) {
1025
1067
  }
1026
1068
 
1027
1069
  /**
1028
- * 三级菜单选项:还原最后一次会话 / 直接进入 / 退出,固定顺序
1070
+ * 三级菜单选项:选择历史会话 / 直接进入 / 退出,固定顺序
1029
1071
  * @returns {Array<object>}
1030
1072
  */
1031
1073
  function buildPostSwitchChoices() {
1032
1074
  return [
1033
- { name: '还原最后一次会话并进入 codex', value: POST_SWITCH_RESUME },
1075
+ { name: '选择历史会话并进入 codex', value: POST_SWITCH_RESUME },
1034
1076
  { name: '直接进入 codex', value: POST_SWITCH_DIRECT },
1035
1077
  { name: '退出 ucx', value: POST_SWITCH_EXIT },
1036
1078
  ];
1037
1079
  }
1038
1080
 
1039
1081
  /**
1040
- * 启动 codex 并等待其执行完成,返回子进程退出码;codex 缺失时提示并返回 null
1082
+ * 还原会话列表:最近若干条会话 + 返回上级,固定返回项在末尾
1083
+ * @param {Array<{ id: string, threadName: string }>} sessions 新到旧的会话
1084
+ * @returns {Array<object>}
1085
+ */
1086
+ function buildResumeSessionChoices(sessions) {
1087
+ var choices = (sessions || []).map(function (session) {
1088
+ return { name: formatSessionChoiceName(session), value: session.id };
1089
+ });
1090
+ choices.push({ name: '*返回上级*', value: POST_SWITCH_RESUME_BACK });
1091
+ return choices;
1092
+ }
1093
+
1094
+ /**
1095
+ * 前台启动 codex 并等待其执行完成,返回其退出码;codex 缺失时提示并返回 null,spawn 失败向上抛错
1096
+ * 关键点:inquirer 菜单用过 stdin 后,父进程的 stdin 上可能仍挂着操作系统层面的读取,
1097
+ * 会抢走子进程的键盘输入(codex 界面起来但无法输入);jgrok 没这个问题是因为它从不碰 stdin。
1098
+ * 因此启动前恢复 cooked 模式,真实启动时 spawn 后立即销毁父进程 stdin,把输入完全让给 codex。
1041
1099
  * @param {Array<string>} args 传给 codex 的参数
1042
1100
  * @param {string} homeDir 用户主目录
1043
1101
  * @param {object} [options] 可选配置,spawnImpl/execImpl 用于单测注入
1044
- * @returns {number|null}
1102
+ * @returns {Promise<number|null>}
1045
1103
  */
1046
- function launchCodex(args, homeDir, options) {
1104
+ async function launchCodex(args, homeDir, options) {
1047
1105
  options = options || {};
1048
1106
  // 测试环境且未注入实现时跳过,避免真实启动 codex
1049
1107
  if (process.env.JCC_TEST_HOME && !options.spawnImpl) {
@@ -1054,24 +1112,46 @@ function launchCodex(args, homeDir, options) {
1054
1112
  console.error('未检测到 codex 命令,请先安装 Codex CLI 后重试。');
1055
1113
  return null;
1056
1114
  }
1057
- var spawn = options.spawnImpl || childProcess.spawnSync;
1058
- var result = spawn('codex', args || [], {
1115
+ // 恢复 cooked 模式,给子进程一个与 jgrok 场景一致的干净终端基线
1116
+ if (process.stdin && process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
1117
+ try {
1118
+ process.stdin.setRawMode(false);
1119
+ } catch (e) {
1120
+ // 模式恢复失败不阻断启动
1121
+ }
1122
+ }
1123
+ var spawn = options.spawnImpl || childProcess.spawn;
1124
+ var child = spawn('codex', args || [], {
1059
1125
  stdio: 'inherit',
1060
1126
  shell: process.platform === 'win32',
1061
1127
  env: buildCodexChildEnv(homeDir),
1062
- }) || {};
1063
- if (result.error) {
1064
- throw result.error;
1065
- }
1066
- if (result.signal) {
1067
- return 1;
1128
+ });
1129
+ // 真实启动时销毁父进程 stdin,取消挂起读取;句柄已在 spawn 时复制给子进程,不受影响。
1130
+ // 注入 spawnImpl 的单测不销毁,避免破坏测试进程自身的 stdin。
1131
+ if (!options.spawnImpl && process.stdin && typeof process.stdin.destroy === 'function') {
1132
+ if (typeof process.stdin.pause === 'function') {
1133
+ process.stdin.pause();
1134
+ }
1135
+ process.stdin.destroy();
1068
1136
  }
1069
- return typeof result.status === 'number' ? result.status : 0;
1137
+ var exitCode = await new Promise(function (resolve, reject) {
1138
+ child.on('error', function (err) {
1139
+ reject(err);
1140
+ });
1141
+ child.on('close', function (code, signal) {
1142
+ if (signal) {
1143
+ resolve(1);
1144
+ return;
1145
+ }
1146
+ resolve(typeof code === 'number' ? code : 0);
1147
+ });
1148
+ });
1149
+ return exitCode;
1070
1150
  }
1071
1151
 
1072
1152
  /**
1073
- * 子账号切换后的三级菜单:还原最后一次会话 / 直接进入 codex / 退出 ucx
1074
- * 还原时读 ~/.codex/session_index.jsonl 取最后一次会话 id 后执行 codex fork <id> --yolo
1153
+ * 子账号切换后的三级菜单:选择历史会话 / 直接进入 codex / 退出 ucx
1154
+ * 还原时读 ~/.codex/session_index.jsonl 倒序去重列出最近 8 条,最新在上并默认选中,选中后再 fork
1075
1155
  * @param {string} homeDir 用户主目录
1076
1156
  * @param {object} [options] 可选配置,spawnImpl/execImpl 用于单测注入
1077
1157
  * @returns {Promise<number|undefined>} codex 退出码;未启动 codex 时为 undefined
@@ -1093,15 +1173,34 @@ async function runPostSwitchMenu(homeDir, options) {
1093
1173
  return launchCodex(['--yolo'], homeDir, options);
1094
1174
  }
1095
1175
 
1096
- // 还原最后一次会话:无会话记录时提示并重新选择,用户可改选直接进入
1097
- var session = readLastSessionRecord(homeDir);
1098
- if (!session) {
1176
+ // 还原会话:无记录时提示并重选;有记录则列出最近 8 条供选择,默认最新
1177
+ var sessions = readRecentSessionRecords(homeDir, RESUME_SESSION_LIMIT);
1178
+ if (!sessions.length) {
1099
1179
  console.log('未在 ~/.codex/session_index.jsonl 中找到会话记录,无法还原。');
1100
1180
  continue;
1101
1181
  }
1102
- var display = session.threadName || session.id;
1103
- console.log('正在还原最后一次会话:' + display);
1104
- return launchCodex(['fork', session.id, '--yolo'], homeDir, options);
1182
+ var sessionAnswer = await inquirer.prompt([{
1183
+ type: 'list',
1184
+ name: 'sessionId',
1185
+ message: '请选择要还原的会话',
1186
+ choices: buildResumeSessionChoices(sessions),
1187
+ default: sessions[0].id,
1188
+ }]);
1189
+ if (sessionAnswer.sessionId === POST_SWITCH_RESUME_BACK) {
1190
+ continue;
1191
+ }
1192
+ var selectedSession = null;
1193
+ sessions.forEach(function (item) {
1194
+ if (item.id === sessionAnswer.sessionId) {
1195
+ selectedSession = item;
1196
+ }
1197
+ });
1198
+ if (!selectedSession) {
1199
+ continue;
1200
+ }
1201
+ var display = selectedSession.threadName || selectedSession.id;
1202
+ console.log('正在还原会话:' + display);
1203
+ return launchCodex(['fork', selectedSession.id, '--yolo'], homeDir, options);
1105
1204
  }
1106
1205
  }
1107
1206
 
@@ -1207,6 +1306,8 @@ runUcx._private = {
1207
1306
  POST_SWITCH_RESUME: POST_SWITCH_RESUME,
1208
1307
  POST_SWITCH_DIRECT: POST_SWITCH_DIRECT,
1209
1308
  POST_SWITCH_EXIT: POST_SWITCH_EXIT,
1309
+ POST_SWITCH_RESUME_BACK: POST_SWITCH_RESUME_BACK,
1310
+ RESUME_SESSION_LIMIT: RESUME_SESSION_LIMIT,
1210
1311
  SUB_DIR_PREFIX: SUB_DIR_PREFIX,
1211
1312
  SUB_FILE_NAMES: SUB_FILE_NAMES,
1212
1313
  CODEX_ENV_DEMO: CODEX_ENV_DEMO,
@@ -1246,7 +1347,10 @@ runUcx._private = {
1246
1347
  parseCodexEnvContent: parseCodexEnvContent,
1247
1348
  buildCodexChildEnv: buildCodexChildEnv,
1248
1349
  codexCommandExists: codexCommandExists,
1350
+ readRecentSessionRecords: readRecentSessionRecords,
1249
1351
  readLastSessionRecord: readLastSessionRecord,
1352
+ formatSessionChoiceName: formatSessionChoiceName,
1353
+ buildResumeSessionChoices: buildResumeSessionChoices,
1250
1354
  buildPostSwitchChoices: buildPostSwitchChoices,
1251
1355
  launchCodex: launchCodex,
1252
1356
  runPostSwitchMenu: runPostSwitchMenu,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "base_parts_ai",
3
- "version": "1.0.59",
3
+ "version": "1.0.61",
4
4
  "description": "jaskle base_parts_ai",
5
5
  "main": "./main.js",
6
6
  "registry": true,