ai_co_discuss 0.3.92 → 0.3.94

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/docs/runner.md CHANGED
@@ -159,7 +159,7 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
159
159
 
160
160
  ## 常见问题
161
161
 
162
- - **软件送达失败**:查看成员状态与原位失败卡,在对应软件中处理登录或权限;倒计时期间可以立即重试或停止。三次重试用尽后,讨论专家的失败交给主持人处理;也可跳过本次发言。
162
+ - **软件送达失败**:查看成员状态与原位失败卡,在对应软件中处理登录或权限;倒计时期间可以立即重试或停止。本机接入程序重启或异常退出、导致进行中的软件进程随之结束时,同样自动重新投递同一执行,间隔缩短为 5 秒。三次重试用尽后,讨论专家的失败交给主持人处理;也可跳过本次发言。
163
163
  - **Codex 提示会话被占用**:原聊天仍被 Codex App 占用,可在进度看板切换到 App 接入。
164
164
  - **Windows 报告两处数据路径**:从普通终端管理服务,或设置环境变量 `DISCUSS_SERVICE_HOME`。
165
165
  - **结果不明**:已交付但无法确认的任务不会自动重发,请先在原会话与房间中核对。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.92",
3
+ "version": "0.3.94",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -19,7 +19,8 @@ const AGY_MODEL_TAG = [0xe2, 0x01];
19
19
 
20
20
  /**
21
21
  * One operation a headless agy run refused, from the failed tool step, with the project grant that would allow it.
22
- * agy names the check that failed: a sandboxed or unsandboxed command, or a file write.
22
+ * agy names the check that failed: a sandboxed or unsandboxed command, a file write, or (1.5.3+) a file read outside
23
+ * the project folders.
23
24
  */
24
25
  export function agy_denial(step) {
25
26
  const message = step.tool_info?.error?.message || '';
@@ -28,9 +29,9 @@ export function agy_denial(step) {
28
29
  // Older agy did not name the check; a refused step with a command line was a command.
29
30
  const kind = message.match(/permission check failed for (\w+)/)?.[1] || (parameters.CommandLine ? 'command' : '');
30
31
  if (!kind) return null;
31
- if (kind === 'write_file') {
32
- const path = String(parameters.Path || parameters.TargetFile || message.match(/write_file\(([^)]+)\)/)?.[1] || '');
33
- return { tool: 'write_file', detail: path.slice(0, DENIAL_FIELD), rule: path ? `write_file(${resolve(dirname(path)).replaceAll('\\', '/')})` : '' };
32
+ if (kind === 'write_file' || kind === 'read_file') {
33
+ const path = String(parameters.Path || parameters.TargetFile || message.match(new RegExp(`${kind}\\(([^)]+)\\)`))?.[1] || '');
34
+ return { tool: kind, detail: path.slice(0, DENIAL_FIELD), rule: path ? `${kind}(${resolve(dirname(path)).replaceAll('\\', '/')})` : '' };
34
35
  }
35
36
  const command = String(parameters.CommandLine || '');
36
37
  const rule = `${kind === 'unsandboxed' ? 'unsandboxed_command' : 'command'}(${command})`;
@@ -53,6 +54,18 @@ function check_agy_shell(user_home) {
53
54
  }
54
55
  }
55
56
 
57
+ /**
58
+ * Skill folders agy loads by itself: its own folder and every entry in its global skills.json. agy shows the model these
59
+ * skills' paths, so a run must be able to read them (1.5.3 refuses reads outside the added directories); agy leaves ~/
60
+ * unexpanded on Windows.
61
+ */
62
+ export function agy_skill_directories(user_home = homedir()) {
63
+ const entries = read_json(join(user_home, '.gemini', 'config', 'skills.json')).entries;
64
+ const listed = (Array.isArray(entries) ? entries : []).map(entry => entry?.path).filter(value => typeof value === 'string')
65
+ .map(value => process.platform !== 'win32' && value.startsWith('~/') ? join(user_home, value.slice(2)) : value);
66
+ return [join(user_home, '.gemini', 'config', 'skills'), ...listed].filter(value => isAbsolute(value) && existsSync(value));
67
+ }
68
+
56
69
  /** Test directory containment on the current platform, including Windows drive boundaries. */
57
70
  function within_directory(parent, child) {
58
71
  const path = relative(resolve(parent), resolve(child));
@@ -94,7 +107,7 @@ function agy_project_grants(config, task, resources, user_home) {
94
107
  const read_only = (task.extra_directories || []).filter(item => item.access === 'read').map(item => item.path);
95
108
  const projects = [...new Set([project_directory(config), ...folders].filter(directory => directory && !same_path(directory, task.draft_directory)
96
109
  && !read_only.some(path => same_path(path, directory))))];
97
- const protected_paths = [task.directory, ...task.skill_directories, join(user_home, '.gemini'),
110
+ const protected_paths = [task.directory, ...task.skill_directories, ...agy_skill_directories(user_home), join(user_home, '.gemini'),
98
111
  ...(config.project_access === 'read' ? projects : []), ...read_only];
99
112
  if (protected_paths.some(directory => within_directory(directory, task.draft_directory))) {
100
113
  throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
@@ -156,7 +169,8 @@ export function remove_agy_projects(directory, user_home = homedir()) {
156
169
  function invocation(config, binding, task, user_home) {
157
170
  const project = config.session_id ? null : prepare_agy_project(config, binding, task, user_home);
158
171
  const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
159
- '--add-dir', task.draft_directory, ...[...task.skill_directories, ...(task.extra_directories || []).map(item => item.path)].flatMap(directory => ['--add-dir', directory])];
172
+ '--add-dir', task.draft_directory, ...[...task.skill_directories, ...agy_skill_directories(user_home),
173
+ ...(task.extra_directories || []).map(item => item.path)].flatMap(directory => ['--add-dir', directory])];
160
174
  // Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11) though its interactive window
161
175
  // offers it; 全放开 uses the flag its own denial message suggests on top of the open project grants.
162
176
  args.push('--mode', 'accept-edits', ...(config.project_access === 'full' ? ['--dangerously-skip-permissions'] : []),
@@ -176,7 +190,8 @@ function lifecycle(state, event, task, bind) {
176
190
  const denied = step?.state === 'ERROR' ? agy_denial(step) : null;
177
191
  if (denied) {
178
192
  if (!state.denials.some(item => item.detail === denied.detail) && state.denials.length < MAX_DENIALS) state.denials.push(denied);
179
- state.denial = `${denied.tool === 'write_file' ? '写入' : '运行'}了未授权的${denied.tool === 'write_file' ? '文件' : '命令'}「${denied.detail.slice(0, 120)}」,无人值守模式下无法确认权限而被自动拒绝,随后会话结束`;
193
+ const action = { write_file: '写入了未授权的文件', read_file: '读取了未授权的文件' }[denied.tool] || '运行了未授权的命令';
194
+ state.denial = `${action}「${denied.detail.slice(0, 120)}」,无人值守模式下无法确认权限而被自动拒绝,随后会话结束`;
180
195
  }
181
196
  if (event.event === 'result') {
182
197
  const result = event.result || {};
@@ -25,6 +25,8 @@ export const GOAL_IDLE_MS = 30_000;
25
25
  // A manual member's session that refuses a delivery is retried in the same execution, after a wait the owner can cut short.
26
26
  const MAX_DELIVERY_RETRIES = 3;
27
27
  const RETRY_DELAY_SECONDS = 60;
28
+ // The cause is gone once the restarted runner is back, so its retry needs no long wait.
29
+ const RESTART_RETRY_DELAY_SECONDS = 5;
28
30
  const MAX_RETRY_REASON = 500;
29
31
  const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running', 'retry_wait'];
30
32
  // The longest private chat reply the site keeps.
@@ -912,9 +914,10 @@ export class FreeRoomRunner {
912
914
  }
913
915
  // A restart between persisting and invoking cannot prove non-delivery, so it never resends.
914
916
  if (row.status === 'delivering') return this.fail(member.name, execution.id, '上次投递结果不明,未自动重发;请核对原会话后重新发送');
915
- // Inherited from a runner that stopped or crashed: once its software processes are gone, no answer can come.
917
+ // Inherited from a runner that stopped or crashed: once its software processes are gone, no answer can come, so the
918
+ // same execution is delivered again after the retry wait instead of handing the failure to the host.
916
919
  if (row.status === 'running' && this.orphan(execution.id)?.alive === false) {
917
- return this.fail(member.name, execution.id, '本机接入程序重启或异常退出,这次执行的软件进程已结束,未提交回答;请重新发送');
920
+ return this.retry_or_fail(member.name, execution, '本机接入程序重启或异常退出,这次执行的软件进程已结束,未提交回答', { restarted: true });
918
921
  }
919
922
  if (row.error) return this.retry_or_fail(member.name, execution, row.error);
920
923
  if (this.exited.has(execution.id)) {
@@ -1073,19 +1076,19 @@ export class FreeRoomRunner {
1073
1076
  }
1074
1077
  /**
1075
1078
  * A manual member's session refused the delivery, or the software failed before any turn ran: keep the execution and
1076
- * deliver again after a wait shown on the website, up to MAX_DELIVERY_RETRIES times; anything else, or the last
1077
- * attempt, fails as before.
1079
+ * deliver again after a wait shown on the website, up to MAX_DELIVERY_RETRIES times; so is an execution whose software
1080
+ * ended with the previous runner process (`restarted`). Anything else, or the last attempt, fails as before.
1078
1081
  */
1079
- async retry_or_fail(name, execution, reason) {
1082
+ async retry_or_fail(name, execution, reason, { restarted = false } = {}) {
1080
1083
  const attempt = (execution.retry?.attempt || 0) + 1;
1081
1084
  const not_started = this.not_started.delete(execution.id);
1082
1085
  if (this.not_retryable.delete(execution.id)) return this.fail(name, execution.id, reason);
1083
- if (this.config.participants[name]?.project_access !== 'own' && !not_started) return this.fail(name, execution.id, reason);
1086
+ if (!restarted && this.config.participants[name]?.project_access !== 'own' && !not_started) return this.fail(name, execution.id, reason);
1084
1087
  if (attempt > MAX_DELIVERY_RETRIES) return this.fail(name, execution.id, `${reason}(已自动重试 ${MAX_DELIVERY_RETRIES} 次)`);
1085
1088
  this.stop_member(name);
1086
1089
  this.exited.delete(execution.id);
1087
1090
  await api(this.config, `/executions/${execution.id}`, { action: 'retry_wait', attempt,
1088
- retry_at: Math.floor(Date.now() / 1000) + RETRY_DELAY_SECONDS, reason: reason.slice(0, MAX_RETRY_REASON) });
1091
+ retry_at: Math.floor(Date.now() / 1000) + (restarted ? RESTART_RETRY_DELAY_SECONDS : RETRY_DELAY_SECONDS), reason: reason.slice(0, MAX_RETRY_REASON) });
1089
1092
  this.save(name, { status: 'retry_wait', error: '' });
1090
1093
  }
1091
1094
  /** Record the cause on the website, then stop this member's own processes. */