@yanglaofish/fishtools 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +4 -2
  2. package/manual/README.md +4 -2
  3. package/manual/docs/deployment.md +2 -2
  4. package/manual/docs/modules.md +2 -0
  5. package/manual/docs/plugin-development.md +3 -1
  6. package/manual/docs/releases.md +44 -6
  7. package/manual/docs/superpowers/plans/2026-10-13-command-shortcuts.md +107 -0
  8. package/manual/docs/superpowers/reviews/2026-10-13-0.5.0-followup-review.md +70 -0
  9. package/manual/docs/superpowers/reviews/2026-10-13-command-shortcuts-review.md +63 -0
  10. package/manual/docs/superpowers/specs/2026-10-12-workflow-variation-design.md +1 -1
  11. package/manual/docs/superpowers/specs/2026-10-13-command-shortcuts-design.md +56 -0
  12. package/manual/docs/superpowers/specs/2026-10-13-issue-4-workflow-menu.md +28 -0
  13. package/manual/docs/terminal-commands.md +64 -0
  14. package/manual/docs/verification.md +39 -1
  15. package/manual/packages/core/README.md +3 -1
  16. package/manual/packages/core/package.json +1 -0
  17. package/manual/packages/core/src/application.js +126 -20
  18. package/manual/packages/core/src/export-snapshot.js +20 -1
  19. package/manual/packages/core/src/version.js +1 -1
  20. package/manual/packages/fst-cli/README.md +8 -1
  21. package/manual/packages/fst-cli/package.json +1 -0
  22. package/manual/packages/fst-cli/src/terminal/command-menu.js +186 -0
  23. package/manual/packages/fst-cli/src/terminal/navigation.js +4 -3
  24. package/manual/packages/fst-cli/src/terminal/run-feed.js +24 -9
  25. package/manual/packages/fst-cli/src/terminal/session.js +35 -4
  26. package/manual/packages/fst-cli/src/terminal/view.js +43 -8
  27. package/manual/packages/fst-cli/src/terminal/workflow-menu.js +1 -6
  28. package/manual/packages/fst-devtools/src/documentation.js +2 -1
  29. package/manual/packages/fst-logs/README.md +11 -0
  30. package/manual/packages/fst-logs/package.json +2 -0
  31. package/manual/packages/fst-logs/src/run-logs.js +8 -23
  32. package/manual/packages/fst-logs/src/run-preview.js +18 -0
  33. package/manual/packages/fst-logs/src/step-summaries.js +28 -0
  34. package/manual/packages/fst-platform/README.md +17 -0
  35. package/manual/packages/fst-platform/package.json +1 -0
  36. package/manual/packages/fst-platform/src/process.js +12 -8
  37. package/manual/packages/fst-platform/src/shell.js +58 -0
  38. package/manual/packages/fst-plugin/src/contracts.js +6 -2
  39. package/manual/packages/fst-plugin/src/host.js +7 -4
  40. package/manual/packages/fst-plugin/src/runtime.js +8 -5
  41. package/manual/packages/fst-workspace/README.md +3 -0
  42. package/manual/packages/fst-workspace/package.json +1 -0
  43. package/manual/packages/fst-workspace/src/shortcuts.js +119 -0
  44. package/manual/packages/pack/index.js +1 -1
  45. package/manual/plugins/commands/LICENSE +21 -0
  46. package/manual/plugins/commands/README.md +41 -0
  47. package/manual/plugins/commands/index.js +94 -0
  48. package/manual/plugins/commands/package.json +17 -0
  49. package/node_modules/@mytools/commands/LICENSE +21 -0
  50. package/node_modules/@mytools/commands/README.md +41 -0
  51. package/node_modules/@mytools/commands/index.js +94 -0
  52. package/node_modules/@mytools/commands/package.json +17 -0
  53. package/node_modules/@mytools/core/README.md +3 -1
  54. package/node_modules/@mytools/core/package.json +1 -0
  55. package/node_modules/@mytools/core/src/application.js +126 -20
  56. package/node_modules/@mytools/core/src/export-snapshot.js +20 -1
  57. package/node_modules/@mytools/core/src/version.js +1 -1
  58. package/node_modules/@mytools/fst-cli/README.md +8 -1
  59. package/node_modules/@mytools/fst-cli/package.json +1 -0
  60. package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +186 -0
  61. package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +4 -3
  62. package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +24 -9
  63. package/node_modules/@mytools/fst-cli/src/terminal/session.js +35 -4
  64. package/node_modules/@mytools/fst-cli/src/terminal/view.js +43 -8
  65. package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +1 -6
  66. package/node_modules/@mytools/fst-devtools/src/documentation.js +2 -1
  67. package/node_modules/@mytools/fst-logs/README.md +11 -0
  68. package/node_modules/@mytools/fst-logs/package.json +2 -0
  69. package/node_modules/@mytools/fst-logs/src/run-logs.js +8 -23
  70. package/node_modules/@mytools/fst-logs/src/run-preview.js +18 -0
  71. package/node_modules/@mytools/fst-logs/src/step-summaries.js +28 -0
  72. package/node_modules/@mytools/fst-platform/README.md +17 -0
  73. package/node_modules/@mytools/fst-platform/package.json +1 -0
  74. package/node_modules/@mytools/fst-platform/src/process.js +12 -8
  75. package/node_modules/@mytools/fst-platform/src/shell.js +58 -0
  76. package/node_modules/@mytools/fst-plugin/src/contracts.js +6 -2
  77. package/node_modules/@mytools/fst-plugin/src/host.js +7 -4
  78. package/node_modules/@mytools/fst-plugin/src/runtime.js +8 -5
  79. package/node_modules/@mytools/fst-workspace/README.md +3 -0
  80. package/node_modules/@mytools/fst-workspace/package.json +1 -0
  81. package/node_modules/@mytools/fst-workspace/src/shortcuts.js +119 -0
  82. package/node_modules/@mytools/pack/index.js +1 -1
  83. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1212 -1187
  84. package/package.json +3 -1
@@ -1,12 +1,21 @@
1
1
  import {readableValue,readableError,readableTimeline} from './run-presentation.js';
2
2
  import {runStatuses} from './run-viewer.js';
3
+ import {createStepSummaries} from '@mytools/fst-logs/steps';
4
+ import {previewRun} from '@mytools/fst-logs/preview';
5
+
6
+ const feedRecord=(record,maxBytes)=>{
7
+ const {stepSummaries,stepSummaryBytes,stepsTruncated,...state}=record;
8
+ return previewRun({...state,events:[...(state.events??[])]},{maxValueBytes:Math.min(maxBytes,65536)});
9
+ };
3
10
 
4
11
  // 主面板按事件到达顺序追加;运行快照另存,配置菜单不会清掉执行记录。
5
12
  export class RunFeed{
6
13
  constructor(refresh,{maxEvents=1000,maxBytes=1048576,maxRuns=50}={}){Object.assign(this,{refresh,maxEvents,maxBytes,maxRuns});this.runs=new Map();this.entries=[];this.subscriptions=new Map();this.expanded=false;this.dropped=0;}
7
14
  track({app,runId,title,input}){
8
15
  if(this.runs.has(runId))return;
9
- const record=app.getRun(runId),item={app,record,title,input,count:0,ended:false,steps:new Map(),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
16
+ const raw=app.getRun(runId),record=feedRecord(raw,this.maxBytes),bounded=previewRun({input},{maxValueBytes:Math.min(this.maxBytes,65536)});
17
+ const item={app,record,title,input:bounded.input,inputTruncated:bounded.inputTruncated,count:0,ended:false,steps:createStepSummaries({maxEvents:this.maxEvents,maxMemoryBytes:this.maxBytes}),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
18
+ for(const summary of raw.stepSummaries??[])item.steps.append(summary);
10
19
  this.runs.set(runId,item);
11
20
  this.entries.push({type:'start',item});
12
21
  if(!this.subscriptions.has(app))this.subscriptions.set(app,app.subscribe(event=>{
@@ -30,12 +39,13 @@ export class RunFeed{
30
39
  if(event?.type&&!['run.succeeded','run.failed','run.cancelled','run.stopped'].includes(event.type)){item.record.events.push(event);events=[event];item.count++;}
31
40
  else{
32
41
  const record=item.app.getRun(item.record.id);if(!record)return;
33
- item.record={...record,events:[...(record.events??[])]};
42
+ item.record=feedRecord(record,this.maxBytes);
43
+ for(const summary of record.stepSummaries??[])item.steps.append(summary);
34
44
  events=record.events.filter((e,index)=>e.seq!==undefined?e.seq>item.count:index>=item.count);
35
45
  item.count=record.eventCount??record.events.length;
36
46
  }
37
47
  const {record}=item;
38
- for(const event of events)if(event.type.startsWith('step.'))item.steps.set(event.callPath,{...item.steps.get(event.callPath),...event});
48
+ for(const event of events)item.steps.append(event);
39
49
  if(events.length){
40
50
  const last=this.entries.at(-1);
41
51
  if(last?.type==='events'&&last.item===item)last.events.push(...events);
@@ -59,7 +69,6 @@ export class RunFeed{
59
69
  this.entries=this.entries.filter(entry=>!entry.events||entry.events.length);
60
70
  for(const item of this.runs.values()){
61
71
  item.record.events=this.entries.filter(e=>e.item===item&&e.events).flatMap(e=>e.events);
62
- while(item.steps.size>this.maxEvents)item.steps.delete(item.steps.keys().next().value);
63
72
  }
64
73
  const ended=[...this.runs.values()].filter(item=>item.ended);
65
74
  for(const item of ended.slice(0,Math.max(0,ended.length-this.maxRuns))){this.runs.delete(item.record.id);this.entries=this.entries.filter(e=>e.item!==item);this.dropped++;}
@@ -75,10 +84,14 @@ export class RunFeed{
75
84
  for(const event of entry.events){
76
85
  const key=`${item.record.id}\0${event.callPath}`;
77
86
  if(event.type.startsWith('step.')&&!seen.has(key)){
78
- seen.add(key);const latest=item.steps.get(event.callPath),action=item.catalog.get(latest.action);
79
- const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
80
- const name=action?.title??latest.action??latest.stepId??latest.callPath;
81
- rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
87
+ seen.add(key);const latest=item.steps.get(event.callPath);
88
+ if(!latest)rows.push(`· ${event.action??event.stepId??event.callPath} · 摘要已省略 · ${event.callPath}`);
89
+ else{
90
+ const action=item.catalog.get(latest.action);
91
+ const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
92
+ const name=action?.title??latest.action??latest.stepId??latest.callPath;
93
+ rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
94
+ }
82
95
  }
83
96
  if(event.type==='log'&&event.audience==='user')rows.push(`[${event.callPath??item.record.workflowId}] ${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);
84
97
  if(event.type==='step.failed'&&![...item.steps.values()].some(step=>step.type==='step.failed'&&step.callPath?.startsWith(event.callPath+'/')))rows.push(`✗ ${event.callPath}\n${readableError(event.error??{message:event.message})}`);
@@ -86,18 +99,20 @@ export class RunFeed{
86
99
  }
87
100
  return rows.length?`${item.title??item.record.workflowId} · ${item.record.id.slice(0,8)}\n${rows.join('\n')}`:'';
88
101
  }
89
- text(){const seen=new Set();return (this.dropped?'主面板仅保留近期摘要;完整日志可在 /logs 中按 p 分页阅读。\n\n':'')+this.entries.map(entry=>{
102
+ text(){const seen=new Set();return (this.dropped?'主面板仅保留近期摘要;超出预算的事件和步骤摘要已省略。完整日志可在 /logs 中按 p 分页阅读。\n\n':'')+this.entries.map(entry=>{
90
103
  if(entry.type==='message')return entry.text;
91
104
  const {record,title,input}=entry.item,label=`${title??record.workflowId} · ${record.id.slice(0,8)}`;
92
105
  if(entry.type==='start')return [
93
106
  `› ${record.workflowId.startsWith('/')?record.workflowId:'/run '+record.workflowId} · ${label}`,
94
107
  `状态:${runStatuses[record.status]??record.status}`,
95
108
  this.expanded&&input&&Object.keys(input).length?'输入\n'+readableValue(input):'',
109
+ this.expanded&&entry.item.inputTruncated?'输入较大,主面板仅显示摘要。':'',
96
110
  ].filter(Boolean).join('\n');
97
111
  if(entry.type==='events')return this.expanded?`${label}\n${readableTimeline(entry.events,{values:true})}`:this.compactEvents(entry,seen);
98
112
  return [`结果 · ${label} · ${runStatuses[record.status]??record.status}`,
99
113
  ...(record.warnings??[]).map(warning=>`警告:${warning.message}`),
100
114
  this.expanded&&record.output!==undefined?readableValue(record.output):'',record.error&&(this.expanded||![...entry.item.steps.values()].some(step=>step.type==='step.failed'))?readableError(record.error,{details:this.expanded}):'',record.reason??'',
115
+ this.expanded&&record.outputTruncated?'结果较大,主面板仅显示摘要;完整结果请在 /logs 中按 p 分页阅读。':'',
101
116
  ].filter(Boolean).join('\n');
102
117
  }).filter(Boolean).join('\n\n');}
103
118
  close(){if(this.refreshTimer)clearTimeout(this.refreshTimer);this.refreshTimer=null;for(const unsubscribe of this.subscriptions.values())unsubscribe();this.subscriptions.clear();}
@@ -9,6 +9,8 @@ import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
9
9
  import {readableValue,readableError} from './run-presentation.js';
10
10
  import {commandMenu} from './navigation.js';
11
11
  import {workflowMenu} from './workflow-menu.js';
12
+ import {commandPresetsMenu,shortcutsMenu,runShortcut} from './command-menu.js';
13
+ import {validate} from '@mytools/sdk/schema';
12
14
  import {resolvePrivilegeRequirement,checkStaticPrivilege} from '@mytools/fst-plugin/privileges';
13
15
 
14
16
  export class TerminalSession{
@@ -24,7 +26,7 @@ export class TerminalSession{
24
26
  }
25
27
  }
26
28
  current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
27
- commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
29
+ commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
28
30
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
29
31
  async mayExit(){
30
32
  if(!await this.discard())return false;
@@ -41,7 +43,7 @@ export class TerminalSession{
41
43
  while(!this.exiting){
42
44
  this.ui.summary(`欢迎使用 Fishtools · 插件编排工作台\n当前工作区:${this.app.projectDir}\n${this.app.listPlugins().length} 个插件 · ${(await this.app.listWorkflows()).length} 个工作流${this.flow?` · ${this.flow.title??this.flow.id} · ${this.dirty?'未保存':'已保存'}`:''}`);
43
45
  let text;
44
- try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands(),workflows:await this.app.listWorkflows()});}
46
+ try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.commands(),workflows:await this.app.listWorkflows(),multiline:true});}
45
47
  catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
46
48
  if(!text.trim())continue;
47
49
  try{await this.execute(text);}catch(e){if(e.code!=='PROMPT_CANCELLED'||!this.ui.endCommand)this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回主面板。':`${e.code==='COMMAND_INPUT'?'输入提示':'操作失败'}:${e.message}`);}
@@ -57,19 +59,25 @@ export class TerminalSession{
57
59
  finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
58
60
  }
59
61
  async executeCommand(text){
62
+ if(text.trimStart().startsWith('!'))return this.shellCommand(text.trimStart().slice(1));
60
63
  const [command,...args]=parseCommand(text);
64
+ const shortcut=this.app.listShortcuts?.().find(entry=>entry.name===command);
65
+ if(shortcut)return runShortcut(this,shortcut,args);
61
66
  const entry=this.commands().find(c=>c.name===command&&c.plugin);
62
67
  if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
63
68
  const pluginCommand=this.app.listCommands().find(c=>c.name===command);
64
69
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
65
70
  if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
66
71
  switch(command){
72
+ case 'commands':return commandPresetsMenu(this);
73
+ case 'shortcuts':return shortcutsMenu(this);
67
74
  case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
68
75
  '# Fishtools',
69
76
  '将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
70
77
  `当前工作区:${this.app.projectDir}`,
71
78
  `文件路径起点:${this.baseDir}`,
72
79
  '## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
80
+ '## 系统命令与快捷名称','输入 ! 后接系统命令可直接执行,输出和状态保留在主面板;/commands 注册常用命令供快捷执行与工作流编排,/shortcuts 给任意插件动作或命令设置中文快捷名称。主输入框和命令编辑框使用 Shift+Enter 换行、Enter 提交,Alt+Enter 为备用换行;支持多行粘贴。系统命令按当前 Shell 语法执行,目标机器需要已安装相应外部程序。',
73
81
  '## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
74
82
  '## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
75
83
  '## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
@@ -118,14 +126,26 @@ export class TerminalSession{
118
126
  }
119
127
  async pluginCommand(command,args){
120
128
  if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
121
- const input=args.length?await readRunInput(args[0],this.baseDir):command.input?await editValue(this.ui,command.input,{}, {baseDir:this.baseDir,required:true,label:command.description}):{};
129
+ let input;
130
+ if(args.length)input=await readRunInput(args[0],this.baseDir);
131
+ else{
132
+ const initial=structuredClone(command.shortcutInput??{});
133
+ const empty=command.input?.type==='object'&&command.input.properties&&Object.keys(command.input.properties).length===0;
134
+ if(command.shortcutInput!==undefined||empty){
135
+ try{input=validate(command.input,initial,command.description??command.name);}
136
+ catch{input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});}
137
+ }else input=command.input?await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description}):initial;
138
+ }
122
139
  const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
123
140
  const definition=this.app.host.definitions.get(command.plugin);
141
+ if(!definition)throw new Error('目标插件已移除,请重新选择目标后执行');
124
142
  const requirement=resolvePrivilegeRequirement(definition,command.action);
125
143
  const unknown=requirement.declared&&checkStaticPrivilege(requirement).status==='unknown';
126
144
  const allowUnknownPrivileges=unknown?await this.ui.confirm(`/${command.name} 的权限需要运行时核查。若检查结果仍无法确认,是否允许本次继续?\n${requirement.reason??''}\n${requirement.remediation??''}`):false;
127
145
  if(unknown&&!allowUnknownPrivileges)return;
128
- const run=await this.app.startCommand(command.name,input,{ui,allowUnknownPrivileges});
146
+ const targetName=command.targetName??command.name;
147
+ if(!command.actionId&&!this.app.listCommands().some(current=>current.name===targetName&&current.plugin===command.plugin))throw new Error('命令目标插件已变更,请重新选择目标后执行');
148
+ const run=command.actionId?await this.app.startAction(command.actionId,input,{ui,allowUnknownPrivileges}):await this.app.startCommand(targetName,input,{ui,allowUnknownPrivileges,expectedPlugin:command.plugin});
129
149
  if(this.ui.trackRun){
130
150
  this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
131
151
  if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
@@ -135,6 +155,17 @@ export class TerminalSession{
135
155
  try{const result=await run.done;if(!this.ui.trackRun)this.ui.show(`插件执行${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看执行详情与历史日志`);}
136
156
  finally{unsubscribe();done();}
137
157
  }
158
+ async shellCommand(script){
159
+ const run=await this.app.startShell(script);
160
+ if(this.ui.trackRun){
161
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:'系统命令'});
162
+ void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;
163
+ }
164
+ const unsubscribe=this.app.subscribe(event=>{if(event.runId===run.id&&event.type==='log')this.ui.show(event.message);});
165
+ const done=this.ui.busy('正在执行系统命令 · Ctrl+C 取消',()=>this.app.cancelRun(run.id));
166
+ try{const result=await run.done;this.ui.show(`命令${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看完整日志`);}
167
+ finally{unsubscribe();done();}
168
+ }
138
169
  async configure(selectedId){
139
170
  this.writable();
140
171
  const plugins=this.app.listPlugins().filter(p=>p.config);
@@ -76,7 +76,20 @@ export class TerminalView{
76
76
  input(data);
77
77
  },resize);
78
78
  this.header=new Text('',1,0);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('',1,0);
79
- this.hint.render=width=>['',fitLine(' '+palette.muted(this.homeInput?(width<45?`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出')),width)];
79
+ this.hint.render=width=>{
80
+ const lines=[''];
81
+ if(this.editor&&(this.homeInput||this.multilineInput)){
82
+ // 按完整按键分组换行,窄屏也能看清主键和备用键。
83
+ let line='';
84
+ for(const hint of ['Shift+Enter 换行','Enter 提交','Alt+Enter 备用换行']){
85
+ const next=line?line+' · '+hint:hint;
86
+ if(line&&visibleWidth(next)>width-1){lines.push(line);line=hint;}else line=next;
87
+ }
88
+ lines.push(line);
89
+ }
90
+ lines.push(this.homeInput?(width<45?`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):this.editor&&this.multilineInput?'↑↓ 移动 · Tab 补全 · Esc 返回 · Ctrl+C 退出':(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'));
91
+ return lines.map(line=>line?fitLine(' '+palette.muted(line),width):'');
92
+ };
80
93
  const renderTranscript=this.transcript.render.bind(this.transcript);
81
94
  this.headingLines=(width,mark='FISHtools')=>{
82
95
  const room=Math.max(1,width-2),lines=[''];
@@ -127,7 +140,7 @@ export class TerminalView{
127
140
  browseCommands(data){
128
141
  const direction=matchesKey(data,'up')?-1:matchesKey(data,'down')?1:0;
129
142
  // 补全菜单保留方向键;多行编辑保留光标移动,不把配置值放进命令历史。
130
- if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getText().includes('\n')||!this.commandHistory.length)return false;
143
+ if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getExpandedText().includes('\n')||!this.commandHistory.length)return false;
131
144
  const current=this.commandHistoryIndex??this.commandHistory.length;
132
145
  const next=Math.max(0,Math.min(this.commandHistory.length,current+direction));
133
146
  if(next===current)return true;
@@ -235,19 +248,40 @@ export class TerminalView{
235
248
  if(message.startsWith('fst >'))title.render=()=>[];
236
249
  this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
237
250
  try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
238
- if(this.homeInput&&text.trim()&&this.commandHistory.at(-1)!==text.trim()){
239
- this.commandHistory.push(text.trim());if(this.commandHistory.length>100)this.commandHistory.shift();
251
+ // Shell 历史保留末尾换行,避免再次执行时改变反斜杠续行等语义。
252
+ const historyText=text.trimStart().startsWith('!')?text:text.trim();
253
+ if(this.homeInput&&historyText&&this.commandHistory.at(-1)!==historyText){
254
+ this.commandHistory.push(historyText);if(this.commandHistory.length>100)this.commandHistory.shift();
240
255
  }
241
256
  resolve(text);
242
257
  };component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
243
- finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.prompt.clear();this.tui.requestRender();}
258
+ finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.multilineInput=false;this.prompt.clear();this.tui.requestRender();}
244
259
  }
245
- input(message,{value='',commands=[],files=true,workflows=[]}={}){
260
+ input(message,{value='',commands=[],files=true,workflows=[],multiline=false}={}){
246
261
  const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
262
+ const handleInput=editor.handleInput.bind(editor);
263
+ editor.handleInput=data=>{
264
+ // ProcessTerminal 已把分段粘贴合并为完整事件;公开插入接口保留脚本制表符和换行。
265
+ if(data.startsWith('\x1b[200~')&&data.endsWith('\x1b[201~')){
266
+ const text=safe(data.slice(6,-6).replace(/\r\n?/g,'\n'));
267
+ if(this.homeInput||multiline||text.includes('\n')){editor.insertTextAtCursor(text);return;}
268
+ }
269
+ if(matchesKey(data,'shift+enter')||matchesKey(data,'alt+enter')||data==='\x1b[13;2~'){
270
+ editor.insertTextAtCursor('\n');return;
271
+ }
272
+ // 反斜杠属于命令原文,不能触发上游的“反斜杠后 Enter 换行”兼容键位。
273
+ if(data==='\\'){editor.insertTextAtCursor(data);return;}
274
+ if(matchesKey(data,'enter')&&!editor.isShowingAutocomplete()){
275
+ if(!editor.disableSubmit){const text=editor.getExpandedText();editor.setText('');editor.onSubmit?.(text);}
276
+ return;
277
+ }
278
+ handleInput(data);
279
+ };
247
280
  const renderEditor=editor.render.bind(editor);
248
281
  editor.render=width=>{
249
- if(width<8)return renderEditor(width);
250
- const inner=width-4,lines=renderEditor(inner);let closed=false;
282
+ // 上游 visibleWidth 按三个空格计算 Tab;只扩展显示,提交仍保留原始 Tab。
283
+ if(width<8)return renderEditor(width).map(line=>line.replaceAll('\t',' '));
284
+ const inner=width-4,lines=renderEditor(inner).map(line=>line.replaceAll('\t',' '));let closed=false;
251
285
  return lines.map((line,i)=>{
252
286
  if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
253
287
  if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
@@ -256,6 +290,7 @@ export class TerminalView{
256
290
  });
257
291
  };
258
292
  this.homeInput=message.startsWith('fst >');
293
+ this.multilineInput=multiline;
259
294
  this.commandHistoryIndex=null;
260
295
  if(this.homeInput)editor.onChange=text=>{
261
296
  if(this.commandHistoryIndex!==null&&text!==this.commandHistory[this.commandHistoryIndex])this.commandHistoryIndex=null;
@@ -4,7 +4,6 @@ export async function workflowMenu(session){
4
4
  const workflows=await app.listWorkflows();
5
5
  const choice=await ui.select('工作流 · 选择后编辑或执行',[
6
6
  ...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:`${flow.id} · ${flow.steps.length} 步`})),
7
- ...(!app.project.readOnly?workflows.map(flow=>({value:`duplicate:${flow.id}`,label:`复制 ${flow.title??flow.id} 为新工作流`,description:`从已保存版本 ${flow.id} 创建副本` })):[]),
8
7
  ...(!app.project.readOnly?[{value:'new',label:'+ 新建工作流'}]:[]),
9
8
  ...(session.dirty?[{value:'draft',label:`继续编辑草稿:${session.flow.title}`}]:[]),
10
9
  {value:'back',label:'返回主面板'},
@@ -12,11 +11,7 @@ export async function workflowMenu(session){
12
11
  if(choice==='back')return;
13
12
  try{
14
13
  const id=choice.startsWith('workflow:')?choice.slice(9):null;
15
- if(choice.startsWith('duplicate:')){
16
- if(!await session.discard())continue;
17
- await duplicateIntoSession(session,workflows.find(flow=>flow.id===choice.slice(10)));
18
- }
19
- else if(choice==='new'){if(await session.execute('/new')===false)continue;}
14
+ if(choice==='new'){if(await session.execute('/new')===false)continue;}
20
15
  else if(choice!=='draft'&&session.flow?.id!==id){
21
16
  if(!await session.discard())continue;
22
17
  session.flow=structuredClone(workflows.find(flow=>flow.id===id));session.dirty=false;
@@ -3,6 +3,7 @@ import {fileURLToPath} from 'node:url';
3
3
  import {access,readFile,readdir,lstat,mkdir,copyFile,realpath} from 'node:fs/promises';
4
4
 
5
5
  const topics=[
6
+ ['commands','系统命令与快捷名称','docs/terminal-commands.md'],
6
7
  ['plugins','插件开发规范','docs/plugin-development.md'],
7
8
  ['deployment','安装与部署','docs/deployment.md'],
8
9
  ['control','控制流与运行观察规范','docs/superpowers/specs/2026-10-11-control-observation.md'],
@@ -14,7 +15,7 @@ const topics=[
14
15
  ['readme','Fishtools 使用说明','README.md'],
15
16
  ];
16
17
  // 保留文档链接所需的目录布局;参考代码仅供阅读,不是第二份运行时。
17
- const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
18
+ const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/commands','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
18
19
  const excluded=new Set(['node_modules','dist','.git','.cache','__pycache__','.pytest_cache','coverage','public']);
19
20
  const inside=(parent,child)=>{const relative=path.relative(parent,child);return relative===''||(!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative));};
20
21
 
@@ -13,6 +13,8 @@
13
13
  | --- | --- |
14
14
  | `@mytools/fst-logs` | `./src/run-logs.js` |
15
15
  | `@mytools/fst-logs/markdown` | `./src/markdown-log.js` |
16
+ | `@mytools/fst-logs/steps` | `./src/step-summaries.js` |
17
+ | `@mytools/fst-logs/preview` | `./src/run-preview.js` |
16
18
  | `@mytools/fst-logs/package.json` | `./package.json` |
17
19
 
18
20
  仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
@@ -35,6 +37,15 @@
35
37
 
36
38
  快照与历史元数据另有 `stepSummaries`、`stepSummaryBytes`、`stepsTruncated`。步骤摘要按 callPath(缺少时按 stepId)保留最新状态,仅包含类型、定位、动作、时间、序号、耗时及有界原因/错误,不缓存 input/output。步骤摘要使用独立的 maxEvents/maxMemoryBytes 预算,因此两份缓存合计最多为两倍 maxMemoryBytes;单条步骤摘要仍受 maxEventBytes 限制。普通日志淘汰不影响步骤状态;步骤本身超预算淘汰时 stepsTruncated 为 true,展示端应把无法确认的步骤显示为未知状态。
37
39
 
40
+ `createStepSummaries(policy)` 从 `/steps` 入口提供同一套摘要缓存,日志写入器与 TUI 共用。
41
+ `append(event)` 更新状态并刷新淘汰顺序;`get(callPath)`、`values()` 和 `snapshot()` 返回副本。
42
+ `snapshot()` 包含上述三个步骤摘要字段,`size` 为保留条数。
43
+
44
+ `previewRun(record,{maxValueBytes=65536})` 从 `/preview` 入口提供展示与历史元数据共用的结果投影。
45
+ input/output、error、reason 等字段转为有界 JSON 预览,过大时标记 `<字段>Truncated:true`;业务数据对象不变。
46
+ 定位字段与 events/stepSummaries 保留,后两者由各自缓存预算负责;展示端应裁去不使用的缓存副本。
47
+ maxValueBytes 至少为 2,连预览本身也容不下时省略字段值并保留截断标记。
48
+
38
49
  队列预算在追加时检查。未等待 append 的插件同样受到预算约束;超限返回 `LOG_QUEUE_LIMIT` 拒绝,并使 finish 失败。拒绝已安装内部处理,调用方仍可 await 观察失败,不会产生未处理拒绝。`LOG_DISK_LIMIT` 表示可选磁盘预算不足,拒绝完整追加而不静默截断。磁盘预算与队列预算都计算 JSONL 和 Markdown。建议插件等待 append,以便磁盘写入形成背压。
39
50
 
40
51
  `read`/`list` 仅读取受限的 result.json 元数据,运行中的 read 回退到归属元数据;不加载 JSONL 或 Markdown 正文。巨大旧 result.json 超过读取预算时,read 明确返回 `LOG_METADATA_LIMIT`,list 跳过该记录。旧版本缺少归属信息时仅在 `allowLegacy:true` 下可读。
@@ -6,6 +6,8 @@
6
6
  "main": "src/run-logs.js",
7
7
  "exports": {
8
8
  ".": "./src/run-logs.js",
9
+ "./steps": "./src/step-summaries.js",
10
+ "./preview": "./src/run-preview.js",
9
11
  "./markdown": "./src/markdown-log.js",
10
12
  "./package.json": "./package.json"
11
13
  },
@@ -2,7 +2,9 @@ import path from 'node:path';
2
2
  import {readdir,readFile,lstat,unlink,rmdir,mkdir,writeFile,appendFile,open} from 'node:fs/promises';
3
3
  import {FlowError} from '@mytools/sdk';
4
4
  import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
5
- import {logPolicy,jsonSize,preview,eventPreview} from './log-bounds.js';
5
+ import {logPolicy,jsonSize,eventPreview} from './log-bounds.js';
6
+ import {createStepSummaries} from './step-summaries.js';
7
+ import {previewRun} from './run-preview.js';
6
8
 
7
9
  const validId=id=>typeof id==='string'&&/^[a-f0-9-]{36}$/.test(id);
8
10
  const logFiles=['run.md','events.jsonl','result.json','run.meta.json'];
@@ -68,15 +70,15 @@ export function createRunLogs(directory,{projectDir,allowLegacy=false,...options
68
70
  return {
69
71
  list,read,owner,policy:Object.freeze({...policy}),
70
72
  async start(initial,{onEvent=()=>{}}={}){
71
- const directory=location(initial.id),events=[],sizes=[],steps=new Map();
73
+ const directory=location(initial.id),events=[],sizes=[],steps=createStepSummaries(policy);
72
74
  await mkdir(root,{recursive:true});
73
75
  const rootStat=await lstat(root);if(!rootStat.isDirectory()||rootStat.isSymbolicLink())throw new FlowError('日志根目录无效');
74
76
  await mkdir(directory,{recursive:true});
75
77
  await safeDirectory(initial.id);
76
78
  for(const name of logFiles){try{await lstat(path.join(directory,name));throw new FlowError('运行日志已存在,拒绝覆盖');}catch(error){if(error.code!=='ENOENT')throw error;}}
77
79
  const markdownFile=path.join(directory,'run.md');
78
- let writes=Promise.resolve(),failure=null,pendingBytes=0,diskBytes=0,resultBytes=0,eventCount=0,eventBytes=2,eventsTruncated=false,stepSummaryBytes=2,stepsTruncated=false,finished=false;
79
- const snapshot=state=>structuredClone({...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,stepSummaries:[...steps.values()].map(entry=>entry.event),stepSummaryBytes,stepsTruncated});
80
+ let writes=Promise.resolve(),failure=null,pendingBytes=0,diskBytes=0,resultBytes=0,eventCount=0,eventBytes=2,eventsTruncated=false,finished=false;
81
+ const snapshot=state=>structuredClone({...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,...steps.snapshot()});
80
82
  const reject=error=>{failure??=error;const promise=Promise.reject(failure);promise.catch(()=>{});return promise;};
81
83
  function diskCheck(extra){if(policy.maxDiskBytes!==null&&diskBytes+extra>policy.maxDiskBytes)throw new FlowError('日志磁盘预算已满',{code:'LOG_DISK_LIMIT',details:{maxDiskBytes:policy.maxDiskBytes}});}
82
84
  const queue=(bytes,operation)=>{
@@ -95,28 +97,11 @@ export function createRunLogs(directory,{projectDir,allowLegacy=false,...options
95
97
  while(events.length>policy.maxEvents||eventBytes>policy.maxMemoryBytes){
96
98
  const removed=sizes.shift();events.shift();eventBytes-=removed+(events.length?1:0);eventsTruncated=true;
97
99
  }
98
- if(typeof full.type==='string'&&full.type.startsWith('step.')&&(full.callPath!==undefined||full.stepId!==undefined)){
99
- const lightweight={};
100
- for(const key of ['type','callPath','stepId','action','time','seq','durationMs','reason','error'])if(full[key]!==undefined)lightweight[key]=full[key];
101
- const summary=eventPreview(lightweight,policy.maxEventBytes),key=summary.callPath??summary.stepId;
102
- if(key!==undefined&&summary.callPath===full.callPath&&summary.stepId===full.stepId){
103
- const previous=steps.get(key);
104
- if(previous){steps.delete(key);stepSummaryBytes-=previous.bytes+(steps.size?1:0);}
105
- const bytes=Buffer.byteLength(JSON.stringify(summary));steps.set(key,{event:summary,bytes});stepSummaryBytes+=bytes+(steps.size>1?1:0);
106
- while(steps.size>policy.maxEvents||stepSummaryBytes>policy.maxMemoryBytes){
107
- const oldest=steps.keys().next().value,removed=steps.get(oldest);steps.delete(oldest);stepSummaryBytes-=removed.bytes+(steps.size?1:0);stepsTruncated=true;
108
- }
109
- }else stepsTruncated=true;
110
- }
100
+ steps.append(full);
111
101
  onEvent(structuredClone(event));
112
102
  }
113
103
  function metadata(state){
114
- const run={...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,stepSummaries:[...steps.values()].map(entry=>entry.event),stepSummaryBytes,stepsTruncated};
115
- for(const key of Object.keys(run)){
116
- if(['events','stepSummaries','id','workflowId','logOwner','status','startedAt','finishedAt'].includes(key))continue;
117
- if(run[key]===undefined)continue;
118
- const bounded=preview(run[key],policy.maxEventBytes);run[key]=bounded.value;if(bounded.truncated)run[`${key}Truncated`]=true;
119
- }
104
+ const run=previewRun({...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,...steps.snapshot()},{maxValueBytes:policy.maxEventBytes});
120
105
  const text=JSON.stringify(run);
121
106
  if(Buffer.byteLength(text)>metadataLimit)throw new FlowError('运行记录元数据超过写入预算',{code:'LOG_METADATA_LIMIT'});
122
107
  return text;
@@ -0,0 +1,18 @@
1
+ import {FlowError} from '@mytools/sdk';
2
+ import {defaults,preview,jsonSize} from './log-bounds.js';
3
+
4
+ const retainedFields=new Set(['events','stepSummaries','id','workflowId','logOwner','status','startedAt','finishedAt']);
5
+
6
+ // 路由信息与已有独立预算的缓存保留;业务正文和诊断字段只投影有界预览。
7
+ export function previewRun(record,{maxValueBytes=defaults.maxEventBytes}={}){
8
+ if(!Number.isSafeInteger(maxValueBytes)||maxValueBytes<2)throw new FlowError('结果预览 maxValueBytes 必须为至少 2 的整数字节数');
9
+ const run={...record};
10
+ for(const key of Object.keys(run)){
11
+ if(retainedFields.has(key)||run[key]===undefined)continue;
12
+ const bounded=preview(run[key],maxValueBytes);
13
+ if(jsonSize(bounded.value,maxValueBytes)>maxValueBytes)delete run[key];
14
+ else run[key]=bounded.value;
15
+ if(bounded.truncated)run[`${key}Truncated`]=true;
16
+ }
17
+ return run;
18
+ }
@@ -0,0 +1,28 @@
1
+ import {logPolicy,eventPreview} from './log-bounds.js';
2
+
3
+ // 日志写入器与展示端共用轻量摘要,避免各端重新缓存业务 input/output。
4
+ export function createStepSummaries(options={}){
5
+ const policy=logPolicy(options),steps=new Map();
6
+ let bytes=2,truncated=false;
7
+ return {
8
+ append(full){
9
+ if(typeof full.type!=='string'||!full.type.startsWith('step.')||(full.callPath===undefined&&full.stepId===undefined))return;
10
+ const lightweight={};
11
+ for(const key of ['type','callPath','stepId','action','time','seq','durationMs','reason','error'])if(full[key]!==undefined)lightweight[key]=full[key];
12
+ const summary=eventPreview(lightweight,policy.maxEventBytes),key=summary.callPath??summary.stepId;
13
+ if(key===undefined||summary.callPath!==full.callPath||summary.stepId!==full.stepId){truncated=true;return;}
14
+ const previous=steps.get(key);
15
+ if(previous){steps.delete(key);bytes-=previous.bytes+(steps.size?1:0);}
16
+ const size=Buffer.byteLength(JSON.stringify(summary));
17
+ steps.set(key,{event:summary,bytes:size});bytes+=size+(steps.size>1?1:0);
18
+ while(steps.size>policy.maxEvents||bytes>policy.maxMemoryBytes){
19
+ const oldest=steps.keys().next().value,removed=steps.get(oldest);
20
+ steps.delete(oldest);bytes-=removed.bytes+(steps.size?1:0);truncated=true;
21
+ }
22
+ },
23
+ get size(){return steps.size;},
24
+ get(key){return structuredClone(steps.get(key)?.event);},
25
+ values(){return [...steps.values()].map(entry=>structuredClone(entry.event));},
26
+ snapshot(){return {stepSummaries:this.values(),stepSummaryBytes:bytes,stepsTruncated:truncated};},
27
+ };
28
+ }
@@ -16,7 +16,9 @@
16
16
  | `@mytools/fst-platform/bundle` | `./src/bundle.js` |
17
17
  | `@mytools/fst-platform/json` | `./src/json.js` |
18
18
  | `@mytools/fst-platform/npm` | `./src/npm.js` |
19
+ | `@mytools/fst-platform/network` | `./src/network.js` |
19
20
  | `@mytools/fst-platform/fs` | `./src/file-probe.js` |
21
+ | `@mytools/fst-platform/shell` | `./src/shell.js` |
20
22
  | `@mytools/fst-platform/package.json` | `./package.json` |
21
23
 
22
24
  仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
@@ -31,6 +33,21 @@
31
33
  node --test tests/modules.test.js tests/process.test.js tests/environments.test.js
32
34
  ```
33
35
 
36
+ 进程停止专项:`node --test tests/process.test.js tests/process-stream.test.js tests/process-tree-stop.test.js`。
37
+
38
+ ## 子进程环境
39
+
40
+ `/shell` 的 `runShell(script,{shell='auto',runProcess,temporaryDir,...processOptions})` 使用唯一临时脚本文件,避免长脚本超过系统命令行长度上限,运行结束后删除本次文件。默认 Windows PowerShell / Linux sh,可选 powershell、pwsh、sh、bash;PowerShell 使用 UTF-8 BOM,其他 Shell 使用无 BOM UTF-8。插件传入 `ctx.runProcess` 与 `temporaryDir:ctx.outputDir`,保留当前代理、取消与日志规则,并兼容只读包目录。`shellInvocation` 保留构造简短命令参数的兼容接口。
41
+
42
+ `runProcess(file,args,{env,extendEnv=true})` 默认把 `env` 覆盖到宿主环境上。
43
+ 当调用方已按动作代理规则构造完整环境时,传 `extendEnv:false`,防止已移除的代理变量被重新继承。
44
+
45
+ 缓冲模式由平台按每条输出流的原始字节计算 `maxBuffer`,默认每条流 4 MiB;超限返回 `OUTPUT_LIMIT`。
46
+ `maxBuffer` 必须为非负数,`0` 仅允许空输出,`Infinity` 表示调用方显式取消缓冲上限;对象、`NaN` 和负数会在启动前被拒绝。
47
+ 流式模式通过 `onOutput` 消费输出,仅保留 `summaryBytes` 指定的尾部摘要。
48
+ 超限、取消、超时或输出回调失败时,平台终止整棵进程树,等待回收结束后返回错误。
49
+ 停止过程中继续排空并丢弃管道输出,避免提前关闭管道使父进程因 `EPIPE` 退出,导致 Windows `taskkill /T` 无法找到后代进程。
50
+
34
51
  ## 隔离文件探测
35
52
 
36
53
  `createFileProbe({timeout=2000,maxConcurrent=2,maxPending=16,allowRemote=false})` 创建只读探测器。
@@ -6,6 +6,7 @@
6
6
  "main": "src/process.js",
7
7
  "exports": {
8
8
  ".": "./src/process.js",
9
+ "./shell": "./src/shell.js",
9
10
  "./environments": "./src/environments.js",
10
11
  "./bundle": "./src/bundle.js",
11
12
  "./json": "./src/json.js",
@@ -4,11 +4,11 @@ import which from 'which';
4
4
  import { FlowError } from '@mytools/sdk';
5
5
  import path from 'node:path';
6
6
 
7
- const outputText=bytes=>bytes??new Uint8Array();
8
7
  const stripFinalNewline=text=>text.endsWith('\r\n')?text.slice(0,-2):text.endsWith('\n')?text.slice(0,-1):text;
9
8
 
10
- export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput,outputMode='buffer',summaryBytes=65536}={}) {
9
+ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput,outputMode='buffer',summaryBytes=65536}={}) {
11
10
  if(!['buffer','stream'].includes(outputMode))throw new TypeError('outputMode 必须为 buffer 或 stream');
11
+ if(typeof maxBuffer!=='number'||Number.isNaN(maxBuffer)||maxBuffer<0)throw new TypeError('maxBuffer 必须为非负数,可用 Infinity 表示不限制缓冲');
12
12
  const streaming=outputMode==='stream';
13
13
  if(streaming&&typeof onOutput!=='function')throw new TypeError('stream 模式必须提供 onOutput 消费输出');
14
14
  if(!Number.isSafeInteger(summaryBytes)||summaryBytes<0||summaryBytes>1024*1024)throw new TypeError('summaryBytes 必须为 0 到 1048576 的整数');
@@ -33,11 +33,12 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
33
33
  if(!executable)throw Object.assign(new Error(`not found: ${file}`),{code:'ENOENT'});
34
34
  }
35
35
  catch(cause){throw new FlowError(`未找到程序:${file}`,{code:'ENOENT',details:{executable:file},cause});}
36
- // Execa 收集原始字节;文本只在这里按同一编码解码,避免返回值被默认 UTF-8 解码。
37
- const child=execa(executable,args,{cwd,env,reject:false,windowsHide:true,maxBuffer,buffer:!streaming,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});
36
+ // 平台统一收集原始字节和执行上限;Execa 的超限收集器会提前关闭管道,
37
+ // 导致持续输出的父进程因 EPIPE 退出,taskkill 尚未遍历就失去父 PID。
38
+ const child=execa(executable,args,{cwd,env,extendEnv,reject:false,windowsHide:true,maxBuffer,buffer:false,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});
38
39
  let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
39
40
  let markStopped;const stopped=new Promise(resolve=>{markStopped=resolve;});
40
- const outputBytes={stdout:0,stderr:0},tails={stdout:'',stderr:''};let outputTruncated=false;
41
+ const outputBytes={stdout:0,stderr:0},tails={stdout:'',stderr:''},buffers={stdout:[],stderr:[]};let outputTruncated=false;
41
42
  const remember=(name,message)=>{
42
43
  const bytes=Buffer.from(tails[name]+message);let start=Math.max(0,bytes.length-summaryBytes);
43
44
  if(start){outputTruncated=true;while(start<bytes.length&&(bytes[start]&0xc0)===0x80)start++;}
@@ -67,7 +68,8 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
67
68
  const decoder=new TextDecoder(decoderEncoding);
68
69
  try{
69
70
  for await(const chunk of stream){
70
- if(reason)break;outputBytes[name]+=chunk.byteLength;
71
+ // 停止后继续排空并丢弃输出,避免迭代器 break 提前关闭管道触发 EPIPE。
72
+ if(reason)continue;outputBytes[name]+=chunk.byteLength;
71
73
  const message=decoder.decode(chunk,{stream:true});remember(name,message);
72
74
  await Promise.race([emit(name,message),stopped]);
73
75
  }
@@ -81,9 +83,11 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
81
83
  const finish=()=>{if(!ended){ended=true;emit(name,decoder.decode());}};
82
84
  flush.push(finish);stream?.once('end',finish);
83
85
  stream?.on('data',chunk=>{
86
+ if(reason)return;
84
87
  bytes+=Buffer.byteLength(chunk);
85
88
  // 流量超限时立即回收整棵进程树,不继续扩张待落盘的日志队列。
86
89
  if(bytes>maxBuffer){stop('output-limit');return;}
90
+ buffers[name].push(chunk);
87
91
  if(onOutput)emit(name,decoder.decode(chunk,{stream:true}));
88
92
  });
89
93
  }
@@ -104,7 +108,7 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
104
108
  if(reason)throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
105
109
  if(result.isMaxBuffer)throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
106
110
  if(result.exitCode===undefined&&result.failed)throw new FlowError(`无法启动程序 ${file}:${result.shortMessage??result.message}`,{code:result.code??'SPAWN_FAILED'});
107
- const decode=bytes=>stripFinalNewline(new TextDecoder(decoderEncoding).decode(outputText(bytes)));
111
+ const decode=chunks=>stripFinalNewline(new TextDecoder(decoderEncoding).decode(Buffer.concat(chunks)));
108
112
  if(streaming)return {exitCode:result.exitCode,stdout:stripFinalNewline(tails.stdout),stderr:stripFinalNewline(tails.stderr),outputMode,outputTruncated,outputBytes};
109
- return {exitCode:result.exitCode,stdout:decode(result.stdout),stderr:decode(result.stderr)};
113
+ return {exitCode:result.exitCode,stdout:decode(buffers.stdout),stderr:decode(buffers.stderr)};
110
114
  }