@tomflow/chatloop-tools 0.4.13 → 0.4.15

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
@@ -8,7 +8,7 @@ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools --help
8
8
 
9
9
  ```text
10
10
  Usage: chatloop-tools <command> [target]
11
- Commands: doctor, mcp, start, stop, restart, status, tunnel, capabilities
11
+ Commands: doctor, mcp, start, stop, restart, status, upgrade, tunnel, capabilities
12
12
  Run: chatloop-tools <command> --help
13
13
  ```
14
14
 
@@ -22,3 +22,12 @@ Start all enabled tools or one.
22
22
  ```
23
23
 
24
24
  Extension Actions 委托 `@tomflow/chatloop-extension` 公共 CLI,不复制浏览器操作。具体安全边界和协议见 [Local Tools 产品文档](../../docs/local-tools.md),实际部署与已验证状态见 [CURRENT](../../context/CURRENT.md)。
25
+
26
+
27
+ 正式运行环境的版本切换命令:
28
+
29
+ ```sh
30
+ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools upgrade
31
+ ```
32
+
33
+ 升级先独立安装新版本、核实 Supervisor 所有权与运行状态,再通过原 Supervisor 停止旧版、将 Tunnel MCP 启动命令固定到新版本并启动;不在 Tunnel 正处理 MCP 调用时直接自我重启。若返回 `BLOCKED`,先核对原任务回执,不要盲重试。`start` 全量入口在正常用户安装环境检测到版本差异时复用此流程;带隔离状态目录的测试不触发升级。详情见 [Local Tools 主文档](../../docs/local-tools.md)。
package/bin/cli.mjs CHANGED
@@ -10,15 +10,17 @@ import {CONTRACT,ACTIONS,registry,ensurePrivateDir,paths,
10
10
  import {runGateway} from '../lib/gateway.mjs';
11
11
  import {tunnelDoctor,runTunnel} from '../lib/tunnel.mjs';
12
12
  import {promptTunnelRuntimeKey,storeTunnelRuntimeKey} from '../lib/tunnel-auth.mjs';
13
+ import {upgradeRuntime,runtimeNeedsUpgrade} from '../lib/release.mjs';
13
14
 
14
15
  const HELP={
15
- '': 'Usage: chatloop-tools <command> [target]\nCommands: doctor, mcp, start, stop, restart, status, tunnel, capabilities\nRun: chatloop-tools <command> --help',
16
+ '': 'Usage: chatloop-tools <command> [target]\nCommands: doctor, mcp, start, stop, restart, status, upgrade, tunnel, capabilities\nRun: chatloop-tools <command> --help',
16
17
  doctor:'Usage: chatloop-tools doctor\nInspect local readiness without changing services.',
17
18
  mcp:'Usage: chatloop-tools mcp\nServe namespaced MCP tools over stdio.',
18
19
  start:'Usage: chatloop-tools start [desktop-commander|tunnel]\nStart all enabled tools or one.',
19
20
  stop:'Usage: chatloop-tools stop [desktop-commander|tunnel]\nStop all enabled tools or one.',
20
21
  restart:'Usage: chatloop-tools restart [desktop-commander|tunnel]\nRestart all or one.',
21
22
  status:'Usage: chatloop-tools status [desktop-commander|tunnel]\nRead runtime state.',
23
+ upgrade:'Usage: chatloop-tools upgrade\nStage an immutable release; verify owner, then switch the owned runtime and Tunnel together.',
22
24
  tunnel:'Usage: chatloop-tools tunnel <doctor|auth|--version|run>\nTunnel runs under Supervisor; auth requires explicit setup.',
23
25
  capabilities:'Usage: chatloop-tools capabilities\nList supported tool categories.'
24
26
  };
@@ -116,6 +118,15 @@ async function runControl(action=command, params=extra){
116
118
  if(command==='mcp'){
117
119
  if(extra.length){process.stderr.write(JSON.stringify(fail('INVALID_ARGUMENT'))+'\n');process.exitCode=2;}
118
120
  else runGateway();
121
+ }else if(command==='upgrade'){
122
+ const result=await upgradeRuntime({args:extra});
123
+ emit(result);if(result.status!=='OK')process.exitCode=result.status==='STARTING'?4:5;
124
+ }else if(command==='start'&&extra.length===0&&
125
+ !process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES&&
126
+ !process.env.CHAT_LOOP_LOCAL_TOOLS_STATE_DIR&&
127
+ process.env.CHAT_LOOP_SKIP_UPGRADE!=='1'&&runtimeNeedsUpgrade()){
128
+ emit({command:'start',status:'BLOCKED',code:'VERSION_MISMATCH_USE_UPGRADE'});
129
+ process.exitCode=5;
119
130
  }else if(ACTIONS.has(command))await runControl();
120
131
  else if(command==='doctor'){
121
132
  if(extra.length){emit(fail('INVALID_ARGUMENT'));process.exitCode=2;}
@@ -150,7 +161,7 @@ else if(command==='doctor'){
150
161
  }else if(command==='capabilities'){
151
162
  if(extra.length){emit(fail('INVALID_ARGUMENT'));process.exitCode=2;}
152
163
  else emit({contract:'chat-loop.local-tools.v1',command,status:'OK',
153
- implemented:['capabilities','doctor','mcp','start','stop','restart','status','tunnel'],
164
+ implemented:['capabilities','doctor','mcp','start','stop','restart','status','upgrade','tunnel'],
154
165
  lifecycle:'SUPERVISOR_MCP_GATEWAY',mcpTools:'Namespaced tool catalog',
155
166
  tunnel:'BUNDLED_SUPERVISOR_MANAGED'});
156
167
  }else{emit({contract:'chat-loop.local-tools.v1',command,status:'FAILED',code:'UNKNOWN_COMMAND'});process.exitCode=1;}
@@ -11,34 +11,53 @@ const fingerprint=tools=>JSON.stringify(tools.map(({name,inputSchema})=>({name,i
11
11
  const expected=fingerprint(catalog);
12
12
  const err=code=>({code});
13
13
  const result=(tool,before,after,outcome,error=null,pid=null,evidence=null)=>({toolId:tool.id,enabled:tool.enabled,before,after,outcome,pid,evidence,error});
14
- // Cold Desktop Commander initialization can exceed 9s on a busy macOS host.
15
- // Startup has its own budget; ordinary tool calls keep the stricter deadline.
16
- const timeout=ctx=>Math.max(0,Math.min(ctx.remaining(),9000));
17
- export const startupHandshakeBudget=ctx=>Math.max(0,Math.min(ctx.remaining(),24000));
14
+ // Deadlines bound an individual caller's wait, not a child process' lifetime.
15
+ const timeout=ctx=>Math.max(0,ctx.remaining());
16
+ export const startupHandshakeBudget=ctx=>Math.max(0,ctx.remaining());
18
17
  function entryFor(tool){
19
18
  if(tool.id!=='desktop-commander')throw Object.assign(Error('Unknown MCP child'),{code:'UNKNOWN_TOOL'});
20
19
  const pkg=require.resolve('@wonderwhy-er/desktop-commander/package.json');
21
20
  return join(dirname(pkg),'dist','index.js');
22
21
  }
23
- export function createMcpChildAdapter({records,log}){
24
- function request(rec,method,params,ms){
22
+ export function createMcpChildAdapter({records,log},options={}){
23
+ const spawnChild=options.spawnChild||spawn;
24
+ const childEntry=options.childEntry||entryFor;
25
+ function request(rec,method,params,ms,onLate=null){
25
26
  return new Promise((resolve,reject)=>{
26
27
  if(rec.exited||!rec.child.stdin.writable)return reject(Object.assign(Error('Closed'),{code:'TOOL_UNAVAILABLE'}));
27
- if(ms<1)return reject(Object.assign(Error('Deadline'),{code:'TIMEOUT'}));
28
+ if(ms!==null&&ms<1)return reject(Object.assign(Error('Caller deadline'),{code:'TIMEOUT'}));
28
29
  const id=randomUUID();
29
- const timer=setTimeout(()=>finish(Object.assign(Error('Deadline'),{code:'TIMEOUT'})),ms);
30
+ let settled=false, timer;
30
31
  const finish=(error,value)=>{
31
- clearTimeout(timer);rec.pending.delete(id);
32
+ clearTimeout(timer);
33
+ rec.pending.delete(id);
34
+ if(rec.unconfirmed.delete(id)){
35
+ log('late-mcp-reply',{toolId:rec.toolId,requestId:id});
36
+ if(onLate)onLate(error,value);
37
+ }
38
+ if(settled)return;
39
+ settled=true;
32
40
  error?reject(error):resolve(value);
33
41
  };
34
42
  rec.pending.set(id,finish);
43
+ if(ms!==null)timer=setTimeout(()=>{
44
+ if(settled)return;
45
+ settled=true;
46
+ // Keep the callback until the real reply or child exit. Never treat the
47
+ // caller's timeout as evidence that the child is dead or retry the task.
48
+ if(method==='tools/call'){
49
+ rec.unconfirmed.add(id);
50
+ log('mcp-call-unconfirmed',{toolId:rec.toolId,requestId:id});
51
+ }else rec.pending.delete(id);
52
+ reject(Object.assign(Error('Caller deadline'),{code:'TIMEOUT'}));
53
+ },ms);
35
54
  rec.child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n',e=>{
36
55
  if(e&&rec.pending.has(id))finish(Object.assign(e,{code:'TOOL_UNAVAILABLE'}));
37
56
  });
38
57
  });
39
58
  }
40
59
  function setup(tool,child){
41
- const rec={child,pid:child.pid,state:'STARTING',exited:false,pending:new Map(),active:0};
60
+ const rec={child,toolId:tool.id,pid:child.pid,state:'STARTING',exited:false,pending:new Map(),unconfirmed:new Set(),active:0};
42
61
  records.set(tool.id,rec);
43
62
  let buffer='';
44
63
  child.stdout.on('data',chunk=>{
@@ -60,58 +79,75 @@ export function createMcpChildAdapter({records,log}){
60
79
  child.on('exit',(code,signal)=>{
61
80
  rec.exited=true;rec.state=rec.state==='STOPPING'?'STOPPED':'FAILED';
62
81
  for(const settle of rec.pending.values())settle(Object.assign(Error('Child exit'),{code:'TOOL_UNAVAILABLE'}));
63
- rec.pending.clear();log('child-exit',{toolId:tool.id,pid:rec.pid,code,signal});
82
+ rec.pending.clear();rec.unconfirmed.clear();log('child-exit',{toolId:tool.id,pid:rec.pid,code,signal});
64
83
  });
65
84
  return rec;
66
85
  }
67
86
  async function launch(tool,ctx){
68
87
  const old=records.get(tool.id),before=old?.state||'STOPPED';
69
88
  if(old&&!old.exited){
89
+ if(old.state==='STARTING')return result(tool,before,'STARTING','HANDSHAKE_PENDING',null,old.pid,'MCP_INITIALIZING');
90
+ if(old.unconfirmed.size)return result(tool,before,'DEGRADED','UNCONFIRMED_OPERATIONS',err('OPERATION_UNCONFIRMED'),old.pid,'MCP_REPLY_PENDING');
70
91
  if(old.state!=='RUNNING')return result(tool,before,'UNKNOWN','IDENTITY_UNCERTAIN',err('UNKNOWN_RESULT'),old.pid);
71
92
  const health=await inspect(tool,ctx);
72
93
  if(health.after==='RUNNING')return result(tool,before,'RUNNING','ALREADY_RUNNING',null,old.pid,'MCP_TOOLS_LIST');
73
- return result(tool,before,'UNKNOWN','IDENTITY_UNCERTAIN',health.error,old.pid);
94
+ return {...health,outcome:'HEALTH_NOT_CONFIRMED'};
74
95
  }
75
- if(startupHandshakeBudget(ctx)<100)return result(tool,before,before,'DEADLINE_EXPIRED',err('TIMEOUT'));
76
96
  const home=join(paths().dir,'tools',tool.id,'home');
77
97
  mkdirSync(home,{recursive:true,mode:0o700});
78
98
  const env={HOME:home,PATH:process.env.PATH||'/usr/bin:/bin',LANG:process.env.LANG||'en_US.UTF-8',
79
99
  TMPDIR:process.env.TMPDIR||'/tmp',USER:process.env.USER||'',DESKTOP_COMMANDER_DISABLE_TELEMETRY:'true',
80
100
  DC_REMOTE_DEVICE:'false'};
81
- const child=spawn(process.execPath,[entryFor(tool),'--no-onboarding'],
101
+ const child=spawnChild(process.execPath,[childEntry(tool),'--no-onboarding'],
82
102
  {stdio:['pipe','pipe','pipe'],env,windowsHide:true});
83
103
  const rec=setup(tool,child);
84
- try{
85
- const info=await request(rec,'initialize',{protocolVersion:'2025-03-26',
86
- capabilities:{},clientInfo:{name:'chat-loop-local-tools',version:'0.4.4'}},startupHandshakeBudget(ctx));
87
- if(!info?.protocolVersion)throw Object.assign(Error('Handshake failed'),{code:'MCP_HANDSHAKE_FAILED'});
88
- child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n');
89
- const listed=await request(rec,'tools/list',{},startupHandshakeBudget(ctx));
90
- if(!listed||fingerprint(listed.tools||[])!==expected)
91
- throw Object.assign(Error('Tool schema changed'),{code:'SCHEMA_MISMATCH'});
92
- rec.state='RUNNING';log('tool-start',{requestId:ctx.requestId,toolId:tool.id,pid:rec.pid});
93
- return result(tool,before,'RUNNING','STARTED',null,rec.pid,'MCP_TOOLS_LIST');
94
- }catch(e){
95
- rec.state='UNKNOWN';
96
- // Failed start never acquires a reusable healthy process.
97
- if(!rec.exited)child.kill('SIGTERM');
98
- return result(tool,before,'UNKNOWN','START_FAILED',err(e.code||'UNKNOWN_RESULT'),rec.pid,'NO_VALID_HANDSHAKE');
99
- }
104
+ // Initialization may take arbitrarily long. The CLI can return STARTING;
105
+ // the same owned child will become RUNNING when its MCP reply arrives.
106
+ const handshake=(async()=>{
107
+ try{
108
+ const info=await request(rec,'initialize',{protocolVersion:'2025-03-26',
109
+ capabilities:{},clientInfo:{name:'chat-loop-local-tools',version:'0.4.4'}},null);
110
+ if(!info?.protocolVersion)throw Object.assign(Error('Handshake failed'),{code:'MCP_HANDSHAKE_FAILED'});
111
+ child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n');
112
+ const listed=await request(rec,'tools/list',{},null);
113
+ if(!listed||fingerprint(listed.tools||[])!==expected)
114
+ throw Object.assign(Error('Tool schema changed'),{code:'SCHEMA_MISMATCH'});
115
+ if(rec.exited||rec.state==='STOPPING')return result(tool,before,'FAILED','START_FAILED',err('CHILD_EXIT'),rec.pid);
116
+ rec.state='RUNNING';log('tool-start',{requestId:ctx.requestId,toolId:tool.id,pid:rec.pid});
117
+ return result(tool,before,'RUNNING','STARTED',null,rec.pid,'MCP_TOOLS_LIST');
118
+ }catch(e){
119
+ if(!rec.exited)rec.state='UNKNOWN';
120
+ return result(tool,before,'UNKNOWN','START_FAILED',err(e.code||'UNKNOWN_RESULT'),rec.pid,'NO_VALID_HANDSHAKE');
121
+ }
122
+ })();
123
+ const wait=ctx.remaining();
124
+ if(wait<1)return result(tool,before,'STARTING','HANDSHAKE_PENDING',null,rec.pid,'MCP_INITIALIZING');
125
+ return Promise.race([
126
+ handshake,
127
+ new Promise(resolve=>setTimeout(()=>resolve(result(tool,before,'STARTING','HANDSHAKE_PENDING',null,rec.pid,'MCP_INITIALIZING')),wait))
128
+ ]);
100
129
  }
101
130
  async function inspect(tool,ctx){
102
131
  const rec=records.get(tool.id);
103
132
  if(!rec)return result(tool,'STOPPED','STOPPED','OBSERVED',null,null,'NO_OWNED_PROCESS');
104
133
  if(rec.exited)return result(tool,rec.state,rec.state,'OBSERVED',null,null,'CHILD_EXIT');
105
- if(rec.state!=='RUNNING')return result(tool,rec.state,'UNKNOWN','OBSERVED',err('UNKNOWN_RESULT'),rec.pid);
134
+ if(rec.state==='STARTING')return result(tool,'STARTING','STARTING','OBSERVED',null,rec.pid,'MCP_INITIALIZING');
135
+ if(rec.state!=='RUNNING'&&rec.state!=='UNKNOWN')return result(tool,rec.state,'UNKNOWN','OBSERVED',err('UNKNOWN_RESULT'),rec.pid);
136
+ if(rec.unconfirmed.size)return result(tool,'RUNNING','DEGRADED','OBSERVED',err('OPERATION_UNCONFIRMED'),rec.pid,'MCP_REPLY_PENDING');
137
+ if(rec.active)return result(tool,'RUNNING','DEGRADED','OBSERVED',err('ACTIVE_REQUESTS'),rec.pid,'MCP_CALL_IN_FLIGHT');
106
138
  try{
107
139
  const listed=await request(rec,'tools/list',{},Math.min(timeout(ctx),1000));
108
140
  if(fingerprint(listed.tools||[])!==expected)throw Object.assign(Error('Changed schema'),{code:'SCHEMA_MISMATCH'});
109
- return result(tool,'RUNNING','RUNNING','OBSERVED',null,rec.pid,'MCP_TOOLS_LIST');
110
- }catch(e){rec.state='UNKNOWN';return result(tool,'RUNNING','UNKNOWN','OBSERVED',err(e.code||'UNKNOWN_RESULT'),rec.pid);}
141
+ rec.state='RUNNING';return result(tool,'RUNNING','RUNNING','OBSERVED',null,rec.pid,'MCP_TOOLS_LIST');
142
+ }catch(e){
143
+ // One unanswered health probe does not mutate the running child state.
144
+ return result(tool,'RUNNING','DEGRADED','OBSERVED',err(e.code||'UNKNOWN_RESULT'),rec.pid,'MCP_PROBE_UNANSWERED');
145
+ }
111
146
  }
112
147
  async function stop(tool,ctx){
113
148
  const rec=records.get(tool.id),before=rec?.state||'STOPPED';
114
149
  if(!rec||rec.exited)return result(tool,before,'STOPPED','ALREADY_STOPPED',null,null,'NO_LIVE_OWNED_CHILD');
150
+ if(rec.unconfirmed.size||rec.active)return result(tool,before,'DEGRADED','ACTIVE_REQUESTS',err('OPERATION_UNCONFIRMED'),rec.pid,'MCP_REPLY_PENDING');
115
151
  if(timeout(ctx)<1)return result(tool,before,before,'DEADLINE_EXPIRED',err('TIMEOUT'),rec.pid,'NO_SIGNAL_SENT');
116
152
  rec.state='STOPPING';
117
153
  // A direct ChildProcess handle only. Never kill by saved PID or process name.
@@ -129,13 +165,17 @@ export function createMcpChildAdapter({records,log}){
129
165
  const rec=records.get(tool.id);
130
166
  if(!rec||rec.exited||rec.state!=='RUNNING')throw Object.assign(Error('Not running'),{code:'TOOL_UNAVAILABLE'});
131
167
  if(!catalog.some(t=>t.name===name))throw Object.assign(Error('No such tool'),{code:'UNKNOWN_TOOL'});
168
+ // A prior timed-out write may still be running. Read-only probes may still
169
+ // proceed, but never enqueue another uncertain mutating operation.
170
+ const harmless=new Set(['read_file','read_multiple_files','list_directory','get_file_info',
171
+ 'list_sessions','list_processes','get_usage_stats','get_recent_tool_calls']);
172
+ if(rec.unconfirmed.size&&!harmless.has(name))
173
+ throw Object.assign(Error('Previous request outcome unconfirmed'),{code:'OPERATION_UNCONFIRMED'});
132
174
  rec.active++;
133
175
  try{
134
- // Leave time for IPC and Gateway acknowledgements; never exceed the caller's deadline.
135
176
  const ms=Math.max(0,Math.min(ctx.remaining(),toolCallDeadlineMs(args)-2000));
136
- return await request(rec,'tools/call',{name,arguments:args},ms);
137
- }catch(e){if(e.code==='TIMEOUT')rec.state='UNKNOWN';throw e;}
138
- finally{rec.active--;}
177
+ return await request(rec,'tools/call',{name,arguments:args},ms,(error,value)=>ctx.onLate?.(error,value));
178
+ }finally{rec.active--;}
139
179
  }
140
180
  return {launch,stop,inspect,call,catalog};
141
181
  }
@@ -25,7 +25,7 @@ function probe(socketPath,ms=350){
25
25
  function safeUnlink(file){
26
26
  try{unlinkSync(file);}catch(e){if(e.code!=='ENOENT')throw e;}
27
27
  }
28
- export function createTunnelAdapter({records,log},options={}){
28
+ export function createTunnelAdapter({records,log,onChildExit=()=>{}},options={}){
29
29
  const locate=options.locate||bundledTunnel;
30
30
  const spawnChild=options.spawnChild||spawn;
31
31
  const profileDir=options.profileDir||join(homedir(),'.chat-loop','tunnel','profiles');
@@ -68,7 +68,7 @@ export function createTunnelAdapter({records,log},options={}){
68
68
  if(rec.state==='STOPPING'||rec.stopSignaled)
69
69
  return result(t,rec.state,'UNKNOWN','OBSERVED',err('UNKNOWN_RESULT'),rec.pid,'STOP_UNCONFIRMED');
70
70
  const before=rec.state,ready=await probe(socket);
71
- if(!ready){rec.state='UNKNOWN';return result(t,before,'UNKNOWN','OBSERVED',err('READINESS_LOST'),rec.pid,'READYZ_NOT_200');}
71
+ if(!ready)return result(t,before,'DEGRADED','OBSERVED',err('READINESS_UNCONFIRMED'),rec.pid,'READYZ_NOT_200');
72
72
  rec.state='RUNNING';
73
73
  return result(t,before,'RUNNING','OBSERVED',null,rec.pid,'LOCAL_READYZ');
74
74
  }
@@ -118,6 +118,7 @@ export function createTunnelAdapter({records,log},options={}){
118
118
  if(rec.exited)return;
119
119
  rec.exited=true;rec.state=rec.state==='STOPPING'?'STOPPED':'FAILED';
120
120
  forget(rec);log('tunnel-exit',{toolId:t.id,pid:rec.pid,code,signal});
121
+ if(rec.state==='FAILED'&&!rec.authRequired)onChildExit(t,rec);
121
122
  }
122
123
  child.once('exit',onExit);
123
124
  // spawn ENOENT emits error then close, potentially without an exit event.
package/lib/control.mjs CHANGED
@@ -4,7 +4,10 @@ import {homedir} from 'node:os';
4
4
  import {join} from 'node:path';
5
5
 
6
6
  export const CONTRACT = 'chat-loop.local-control.v1';
7
- export const VERSION = '0.4.7';
7
+ export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
8
+ // All peers must be from one published runtime; never mix an old npx Gateway
9
+ // with a newly activated Supervisor under an unchanged protocol banner.
10
+ export const VERSION = PACKAGE_VERSION;
8
11
  // Bounded end-to-end tool calls: long-running Desktop Commander operations may wait up to timeout_ms.
9
12
  export const toolCallDeadlineMs = args => {
10
13
  const requested = Number.isSafeInteger(args?.timeout_ms) && args.timeout_ms > 0 ? args.timeout_ms : 0;
@@ -82,7 +85,7 @@ export function send(request, timeout=3000) {
82
85
  }
83
86
  export async function talk(request,timeout=12000){
84
87
  const hello=await send({action:'__ping'},1200);
85
- if(hello.contract!==CONTRACT||hello.version!==VERSION||
88
+ if(hello.contract!==CONTRACT||hello.version!==VERSION||hello.packageVersion!==PACKAGE_VERSION||
86
89
  hello.registrySignature!==registry().map(t=>t.id+':'+t.enabled).join('|'))
87
90
  throw Object.assign(new Error('Version or registry mismatch'),{code:'INCOMPATIBLE_VERSION'});
88
91
  verifyOwner(hello);
@@ -17,6 +17,10 @@ export const extensionTools=[
17
17
  extensionTool('clean','Clear composer text in one exact verified ChatGPT tab; never sends.',false,
18
18
  {tabId:{type:'integer',minimum:1}},['tabId']),
19
19
  extensionTool('reload','Request reload of Chat Loop Extension; final health is unverified.',false),
20
+ extensionTool('status','Read the unique current Mac Chrome Web Chat Owner; never starts or sends.',true),
21
+ extensionTool('start','After confirmed intent and preflight, ask Extension to start one unique Web Chat; fails if any task owns it.',false,
22
+ {projectId:{type:'string',minLength:1,maxLength:128},prompt:{type:'string',minLength:1,maxLength:4000},confirmed:{const:true}},
23
+ ['projectId','prompt','confirmed']),
20
24
  extensionTool('continue','Submit next-turn text with turnToken and nextPrompt; alternatively query its final status with turnToken and intentId. Only the Extension can send after Turn completion.',false,
21
25
  {turnToken:{type:'string'},nextPrompt:{type:'string',minLength:1,maxLength:4000},
22
26
  intentId:{type:'string',pattern:'^[a-f0-9]{32}$'},lookup:{type:'boolean'}},['turnToken']),
@@ -40,6 +44,8 @@ const commands={
40
44
  chat_dom:['extension','chat','dom'],
41
45
  chat_screenshot:['extension','chat','screenshot'],
42
46
  reload:['extension','reload'],
47
+ status:['extension','status'],
48
+ start:['extension','start'],
43
49
  clean:['extension','clean'],
44
50
  continue:['extension','continue'],
45
51
  handoff:['extension','handoff']
@@ -48,13 +54,19 @@ const fail=code=>({isError:true,content:[{type:'text',text:code}]});
48
54
  export async function forwardExtensionAction(name,args={}) {
49
55
  const invocation=extensionInvocation(name);
50
56
  if(!invocation||!args||typeof args!=='object'||Array.isArray(args))return fail('INVALID_ARGUMENT');
51
- const control=['continue','handoff'].includes(name);
57
+ const control=['continue','handoff','start'].includes(name);
52
58
  if(name==='clean'){
53
59
  if(Object.keys(args).length!==1||!Number.isSafeInteger(args.tabId)||args.tabId<1)
54
60
  return fail('INVALID_ARGUMENT');
55
61
  invocation.args.push(String(args.tabId));
56
62
  }else if(!control && Object.keys(args).length)return fail('INVALID_ARGUMENT');
57
- if(control){
63
+ if(name==='start'){
64
+ if(Object.keys(args).some(k=>!['projectId','prompt','confirmed'].includes(k))
65
+ ||!(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/).test(args.projectId||'')
66
+ ||typeof args.prompt!=='string'||!args.prompt.trim()||args.prompt.length>4000
67
+ ||args.confirmed!==true)return fail('INVALID_ARGUMENT');
68
+ }
69
+ if(control&&name!=='start'){
58
70
  const query=Object.hasOwn(args,'intentId')||args.lookup===true;
59
71
  if(typeof args.turnToken!=='string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(args.turnToken)
60
72
  || Object.keys(args).some(k=>k!=='turnToken' && (query?
@@ -77,7 +89,7 @@ export async function forwardExtensionAction(name,args={}) {
77
89
  cwd:tmpdir(),windowsHide:true,env:process.env});
78
90
  let stdout='',finished=false;
79
91
  const settle=x=>{if(finished)return;finished=true;clearTimeout(timer);resolve(x);};
80
- const timer=setTimeout(()=>{child.kill('SIGTERM');settle(fail('RESULT_TIMEOUT'));},90000);
92
+ const timer=setTimeout(()=>{child.kill('SIGTERM');settle(fail('RESULT_TIMEOUT'));},name==='start'?125000:90000);
81
93
  if(control)child.stdin.end(JSON.stringify(args));
82
94
  child.stdout.on('data',b=>{
83
95
  stdout+=b.toString();
@@ -102,6 +114,16 @@ export function formatExtensionResult(name,response) {
102
114
  return {content:[{type:'text',text:'ACCEPTED_UNVERIFIED: Extension reload requested; healthy restart not confirmed.'}]};
103
115
  if(response?.status!=='OK'||response.action!==name||typeof response.path!=='string')
104
116
  return fail('INVALID_EXTENSION_RESULT');
117
+ if(['start','status'].includes(name)){
118
+ try{
119
+ const proof=JSON.parse(response.content);
120
+ if(name==='start'){
121
+ if(proof.status!=='OK'||!proof.sessionId||!proof.activeChatId||!proof.activeChatUrl)
122
+ return fail('START_RESULT_UNVERIFIED');
123
+ }else if(!['OK','UNVERIFIED'].includes(proof.status))return fail('STATUS_RESULT_UNVERIFIED');
124
+ return {content:[{type:'text',text:JSON.stringify(proof)}]};
125
+ }catch{return fail('INVALID_SESSION_RESULT');}
126
+ }
105
127
  if(['continue','handoff'].includes(name)){
106
128
  try {
107
129
  const proof=JSON.parse(response.content);
package/lib/gateway.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  // Stdio MCP entrypoint: protocol framing and routing only; never owns child processes.
2
2
  import {randomUUID} from 'node:crypto';
3
3
  import {readFileSync} from 'node:fs';
4
- import {CONTRACT,registry,talk,toolCallDeadlineMs} from './control.mjs';
4
+ import {CONTRACT,PACKAGE_VERSION,registry,talk,toolCallDeadlineMs} from './control.mjs';
5
5
  import {extensionTools,forwardExtensionAction} from './extension-proxy.mjs';
6
+ import {runtimeNeedsUpgrade} from './release.mjs';
6
7
 
7
8
  const desktopTools=JSON.parse(readFileSync(new URL('./desktop-commander-tools.json',import.meta.url),'utf8'));
8
9
  const fixtureTool={name:'ping',description:'Inert lifecycle test health check',
@@ -12,10 +13,15 @@ const prefix=id=>id.replaceAll('-','_')+'__';
12
13
  // The pinned child's UI metadata advertises resources this tools-only Gateway does not serve.
13
14
  // Do not expose those references until resources/read is explicitly supported.
14
15
  const extEnabled=()=>process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES!=='1';
16
+ const receiptTool={name:'chat_loop_runtime__operation_status',
17
+ description:'Read a previously timed-out MCP request receipt without replaying its side effects.',
18
+ inputSchema:{type:'object',required:['requestId'],additionalProperties:false,
19
+ properties:{requestId:{type:'string'}}}};
15
20
  const published=()=>[
16
21
  ...registry().filter(t=>t.enabled).flatMap(t=>
17
22
  toolsFor(t).map(({_meta,...x})=>({...x,name:prefix(t.id)+x.name}))),
18
- ...(extEnabled()?extensionTools.map(t=>({...t,name:'chat_loop_extension__'+t.name})):[])
23
+ ...(extEnabled()?extensionTools.map(t=>({...t,name:'chat_loop_extension__'+t.name})):[]),
24
+ receiptTool
19
25
  ];
20
26
  function resolveTool(name){
21
27
  if(extEnabled()&&typeof name==='string'&&name.startsWith('chat_loop_extension__')){
@@ -46,7 +52,7 @@ export function runGateway(){
46
52
  return reject(m.id,-32602,'UNSUPPORTED_MCP_VERSION');
47
53
  connected=true;
48
54
  return reply(m.id,{protocolVersion:m.params.protocolVersion,
49
- capabilities:{tools:{}},serverInfo:{name:'chat-loop-local-tools',version:'0.4.13'}});
55
+ capabilities:{tools:{}},serverInfo:{name:'chat-loop-local-tools',version:PACKAGE_VERSION}});
50
56
  }
51
57
  // A modern MCP client probes discovery before falling back to legacy initialize.
52
58
  // We only implement the legacy handshake era, so report unsupported method.
@@ -55,18 +61,29 @@ export function runGateway(){
55
61
  if(m.method==='ping')return reply(m.id,{});
56
62
  if(m.method==='tools/list')return reply(m.id,{tools:published()});
57
63
  if(m.method==='tools/call'){
58
- const routed=resolveTool(m.params?.name);
64
+ const routed=m.params?.name===receiptTool.name?
65
+ {toolId:'runtime',name:'operation_status'}:resolveTool(m.params?.name);
59
66
  if(!routed)return reply(m.id,failure('UNKNOWN_TOOL'));
60
67
  const args=m.params?.arguments??{};
61
68
  if(args===null||typeof args!=='object'||Array.isArray(args))
62
69
  return reply(m.id,failure('INVALID_ARGUMENT'));
63
70
  try{
71
+ if(routed.toolId==='runtime'){
72
+ const res=await talk({contract:CONTRACT,action:'receipt',requestId:randomUUID(),
73
+ targetRequestId:args.requestId},4000);
74
+ return reply(m.id,res.status==='OK'?{operation:res.operation}:failure('RECEIPT_UNAVAILABLE'));
75
+ }
76
+ if(routed.toolId!=='chat-loop-extension'&&
77
+ !process.env.CHAT_LOOP_LOCAL_TOOLS_STATE_DIR&&
78
+ !process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES&&runtimeNeedsUpgrade())
79
+ return reply(m.id,failure('RUNTIME_UPGRADE_REQUIRED'));
64
80
  if(routed.toolId==='chat-loop-extension')
65
81
  return reply(m.id,await forwardExtensionAction(routed.name,args));
66
- const deadlineMs=toolCallDeadlineMs(args);
67
- const res=await talk({contract:CONTRACT,requestId:randomUUID(),action:'call',
82
+ const deadlineMs=toolCallDeadlineMs(args),requestId=randomUUID();
83
+ const res=await talk({contract:CONTRACT,requestId,action:'call',
68
84
  scope:'tool',toolId:routed.toolId,name:routed.name,args,deadlineMs},deadlineMs+2000);
69
- if(res.status!=='OK')return reply(m.id,failure(res.error?.code||'TOOL_UNAVAILABLE'));
85
+ if(res.status!=='OK')return reply(m.id,failure(res.error?.code==='TIMEOUT'?
86
+ 'OPERATION_UNCONFIRMED:'+requestId:res.error?.code||'TOOL_UNAVAILABLE'));
70
87
  return reply(m.id,res.result);
71
88
  }catch(e){
72
89
  return reply(m.id,failure(['ENOENT','ECONNREFUSED'].includes(e.code)?'TOOL_UNAVAILABLE':
@@ -0,0 +1,136 @@
1
+ // Owner-verified stop-and-start upgrade. Failure is reported, never auto-rolled back.
2
+ import {spawn} from 'node:child_process';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {existsSync,readFileSync,writeFileSync,openSync,closeSync,unlinkSync,mkdirSync,renameSync} from 'node:fs';
5
+ import {homedir} from 'node:os';
6
+ import {join,dirname} from 'node:path';
7
+ import {PACKAGE_VERSION} from './control.mjs';
8
+
9
+ const name='@tomflow/chatloop-tools';
10
+ const entryIn=prefix=>join(prefix,'node_modules','@tomflow','chatloop-tools','bin','cli.mjs');
11
+ const runtimeRoot=()=>join(homedir(),'.chat-loop');
12
+ const readJson=path=>JSON.parse(readFileSync(path,'utf8'));
13
+ const delay=ms=>new Promise(r=>setTimeout(r,ms));
14
+ const safeVersion=v=>/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(v);
15
+ const quote=s=>JSON.stringify(s);
16
+ const freshResult=(status,extra={})=>({command:'upgrade',status,version:PACKAGE_VERSION,...extra});
17
+
18
+ export function upgradeLayout(base=runtimeRoot(),version=PACKAGE_VERSION){
19
+ if(!safeVersion(version))throw Error('INVALID_VERSION');
20
+ return {
21
+ base,version,
22
+ releases:join(base,'releases'),
23
+ stage:join(base,'releases','chatloop-tools-'+version),
24
+ entry:entryIn(join(base,'releases','chatloop-tools-'+version)),
25
+ legacy:entryIn(join(base,'npm')),
26
+ profile:join(base,'tunnel','profiles','chat-loop.yaml'),
27
+ current:join(base,'runtime.json'),
28
+ lock:join(base,'upgrade.lock')
29
+ };
30
+ }
31
+ function atomicWrite(path,content){
32
+ const tmp=path+'.'+randomUUID()+'.tmp';
33
+ writeFileSync(tmp,content,{mode:0o600,flag:'wx'});
34
+ renameSync(tmp,path);
35
+ }
36
+ function run(bin,args,opts={}){
37
+ return new Promise((resolve,reject)=>{
38
+ const child=spawn(bin,args,{env:opts.env||process.env,stdio:['ignore','pipe','pipe'],windowsHide:true});
39
+ let stdout='',stderr='';
40
+ child.stdout.on('data',x=>stdout+=x.toString());
41
+ child.stderr.on('data',x=>stderr+=x.toString().slice(0,2048));
42
+ child.once('error',reject);
43
+ child.once('close',code=>code===0?resolve(stdout):reject(Object.assign(Error('COMMAND_FAILED'),{
44
+ code:'COMMAND_FAILED',command:args[0],status:code,stdout,detail:stderr.slice(-300)})));
45
+ });
46
+ }
47
+ async function cli(entry,action,opts={}){
48
+ let raw;
49
+ try{raw=await run(process.execPath,[entry,action],opts);}
50
+ catch(e){if(!e.stdout)throw e;raw=e.stdout;}
51
+ try{return JSON.parse(raw.trim());}
52
+ catch{throw Object.assign(Error('INVALID_CLI_REPLY'),{code:'INVALID_CLI_REPLY'});}
53
+ }
54
+ async function awaitStopped(entry,opts={}){
55
+ // A completed stop is verified by the former owner's lock disappearing.
56
+ const p=opts.lockPath;
57
+ for(let i=0;i<80;i++){
58
+ if(!existsSync(p))return;
59
+ await delay(100);
60
+ }
61
+ throw Object.assign(Error('OWNER_STILL_RUNNING'),{code:'OWNER_STILL_RUNNING'});
62
+ }
63
+ export function runtimeNeedsUpgrade(base=runtimeRoot(),version=PACKAGE_VERSION){
64
+ const p=upgradeLayout(base,version);
65
+ const runtime=existsSync(p.current)?readJson(p.current):null;
66
+ if(runtime)return runtime.version!==version;
67
+ if(!existsSync(p.legacy))return false;
68
+ const current=readJson(join(dirname(dirname(p.legacy)),'package.json')).version;
69
+ return current!==version;
70
+ }
71
+ export async function upgradeRuntime(options={}){
72
+ if(options.args?.length)return freshResult('FAILED',{code:'INVALID_ARGUMENT'});
73
+ const p=upgradeLayout(options.base,options.version||PACKAGE_VERSION);
74
+ mkdirSync(p.base,{recursive:true,mode:0o700});
75
+ mkdirSync(p.releases,{recursive:true,mode:0o700});
76
+ let fd;
77
+ try{fd=openSync(p.lock,'wx',0o600);}
78
+ catch{return freshResult('BLOCKED',{code:'UPGRADE_IN_PROGRESS'});}
79
+ const runner=options.run||run;
80
+ let oldEntry, oldStatus, phase='preflight';
81
+ try{
82
+ if(!existsSync(p.profile))return freshResult('BLOCKED',{code:'PROFILE_NOT_FOUND'});
83
+ const profile=readJson(p.profile);
84
+ if(!profile?.mcp?.commands?.some(x=>x.channel==='main'))
85
+ return freshResult('BLOCKED',{code:'PROFILE_MCP_NOT_FOUND'});
86
+ const runtime=existsSync(p.current)?readJson(p.current):null;
87
+ oldEntry=runtime?.entry||p.legacy;
88
+ if(!existsSync(oldEntry))return freshResult('BLOCKED',{code:'PREVIOUS_RUNTIME_NOT_FOUND'});
89
+ const currentVersion=readJson(join(dirname(dirname(oldEntry)),'package.json')).version;
90
+ if(currentVersion===p.version&&runtime?.entry===p.entry){
91
+ const active=await cli(p.entry,'status');
92
+ return active.status==='OK'?freshResult('OK',{outcome:'ALREADY_ACTIVE'}):
93
+ freshResult('BLOCKED',{code:'ACTIVE_RUNTIME_NOT_HEALTHY'});
94
+ }
95
+ // Prepare a new immutable install without touching the running service.
96
+ phase='prepare';
97
+ if(!existsSync(p.entry)){
98
+ await runner(process.platform==='win32'?'npm.cmd':'npm',
99
+ ['install','--prefix',p.stage,'--save-exact','--ignore-scripts',name+'@'+p.version],
100
+ {env:process.env});
101
+ }
102
+ if(!existsSync(p.entry)||readJson(join(dirname(dirname(p.entry)),'package.json')).version!==p.version)
103
+ return freshResult('BLOCKED',{code:'STAGE_VERSION_MISMATCH'});
104
+ const ready=await cli(p.entry,'doctor');
105
+ if(ready.status!=='LOCAL_READY')return freshResult('BLOCKED',{code:'STAGE_DOCTOR_FAILED'});
106
+ // The staged Gateway is prepared independently; Tunnel binds only on activation.
107
+ phase='check-owner';
108
+ oldStatus=await cli(oldEntry,'status');
109
+ if(oldStatus.status!=='OK'||oldStatus.results?.some(r=>r.after!=='RUNNING'&&r.after!=='STOPPED'&&r.after!=='DISABLED'))
110
+ return freshResult('BLOCKED',{code:'OWNER_NOT_QUIESCENT',previousStatus:oldStatus.status});
111
+ phase='stop-old';
112
+ const oldStopped=await cli(oldEntry,'stop');
113
+ if(oldStopped.status!=='OK')return freshResult('BLOCKED',{code:'STOP_UNCONFIRMED'});
114
+ phase='verify-stopped';
115
+ await awaitStopped(oldEntry,{lockPath:join(p.base,'local-tools','manager.lock')});
116
+ phase='configure-new';
117
+ const main=profile.mcp.commands.find(x=>x.channel==='main');
118
+ main.command=quote(process.execPath)+' '+quote(p.entry)+' mcp';
119
+ atomicWrite(p.profile,JSON.stringify(profile,null,2)+'\n');
120
+ phase='start-new';
121
+ const started=await cli(p.entry,'start',{env:{...process.env,CHAT_LOOP_SKIP_UPGRADE:'1'}});
122
+ if(!['OK','STARTING'].includes(started.status))
123
+ throw Object.assign(Error('NEW_RUNTIME_UNHEALTHY'),{code:'NEW_RUNTIME_UNHEALTHY'});
124
+ phase='record-new';
125
+ atomicWrite(p.current,JSON.stringify({version:p.version,entry:p.entry,
126
+ status:started.status==='OK'?'ACTIVE':'STARTING'},null,2)+'\n');
127
+ return freshResult(started.status,{outcome:started.status==='OK'?'ACTIVATED':'STARTING'});
128
+ }catch(e){
129
+ // Fail closed and retain the exact failure site. Never restart an unknown
130
+ // old/new owner or mutate another process as a "recovery" side effect.
131
+ return freshResult('BLOCKED',{code:e.code||'UPGRADE_FAILED',phase});
132
+ }finally{
133
+ if(fd!==undefined)closeSync(fd);
134
+ try{unlinkSync(p.lock);}catch{}
135
+ }
136
+ }
@@ -1,17 +1,31 @@
1
1
  import {randomUUID} from 'node:crypto';
2
2
  import {appendFileSync, closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, chmodSync} from 'node:fs';
3
3
  import {createServer} from 'node:net';
4
- import {CONTRACT,VERSION,registry,paths,ensurePrivateDir,ACTIONS} from './control.mjs';
4
+ import {CONTRACT,VERSION,PACKAGE_VERSION,registry,paths,ensurePrivateDir,ACTIONS} from './control.mjs';
5
5
  import {createAdapter} from './adapters/index.mjs';
6
6
 
7
- const catalog=registry(), records=new Map(), queues=new Map(), seen=new Map();
7
+ const catalog=registry(), records=new Map(), queues=new Map(), seen=new Map(), receipts=new Map();
8
8
  const p=paths(), nonce=randomUUID();
9
+ const desiredTunnel=new Set(), recoveryAttempts=new Map();
9
10
  let lockFd, server, idleTimer;
10
11
  const log=(event,detail={})=>{
11
12
  try{appendFileSync(p.log,JSON.stringify({time:new Date().toISOString(),event,...detail})+'\n',{mode:0o600});}catch{}
12
13
  };
13
14
  const error=(code,message=code)=>({code,message});
14
15
  const after=rec=>rec ? rec.state : 'STOPPED';
16
+ function saveReceipt(id,state,result=null,errorCode=null){
17
+ receipts.delete(id);
18
+ receipts.set(id,{state,result,errorCode});
19
+ if(receipts.size>128)receipts.delete(receipts.keys().next().value);
20
+ }
21
+ function receipt(req){
22
+ const id=req?.targetRequestId;
23
+ if(req?.contract!==CONTRACT||typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))
24
+ return bad(req,'INVALID_ARGUMENT');
25
+ const record=receipts.get(id);
26
+ return {contract:CONTRACT,requestId:req.requestId,status:'OK',
27
+ operation:record||{state:'UNKNOWN',result:null,errorCode:'NOT_FOUND'}};
28
+ }
15
29
  function serial(id,fn){
16
30
  const prev=queues.get(id)||Promise.resolve();
17
31
  const next=prev.catch(()=>{}).then(fn);
@@ -21,9 +35,24 @@ function serial(id,fn){
21
35
  }
22
36
  const adapters=new Map();
23
37
  function adapterFor(tool){
24
- if(!adapters.has(tool.kind))adapters.set(tool.kind,createAdapter(tool.kind,{records,log}));
38
+ if(!adapters.has(tool.kind))adapters.set(tool.kind,createAdapter(tool.kind,{records,log,onChildExit:queueTunnelRecovery}));
25
39
  return adapters.get(tool.kind);
26
40
  }
41
+ function queueTunnelRecovery(tool){
42
+ if(!desiredTunnel.has(tool.id))return;
43
+ const now=Date.now(), old=recoveryAttempts.get(tool.id)||[];
44
+ const attempts=old.filter(t=>now-t<300000);
45
+ if(attempts.length>=3){desiredTunnel.delete(tool.id);log('tunnel-recovery-blocked',{toolId:tool.id,reason:'REPEATED_CRASH'});return;}
46
+ attempts.push(now);recoveryAttempts.set(tool.id,attempts);
47
+ // Only the same Supervisor and its owned child handle can relaunch. Explicit
48
+ // stop/update disables the desired state before the old child is signalled.
49
+ void serial(tool.id,async()=>{
50
+ if(!desiredTunnel.has(tool.id))return;
51
+ const reply=await adapterFor(tool).launch(tool,{requestId:'tunnel-recovery',remaining:()=>25000});
52
+ log('tunnel-recovery',{toolId:tool.id,status:reply.after,outcome:reply.outcome});
53
+ if(reply.after!=='RUNNING')desiredTunnel.delete(tool.id);
54
+ }).catch(e=>log('tunnel-recovery-error',{toolId:tool.id,code:e.code||'UNKNOWN_RESULT'}));
55
+ }
27
56
  function targets(req){
28
57
  const found=req.scope==='tool'?catalog.filter(t=>t.id===req.toolId):
29
58
  catalog.filter(t=>req.action==='status'||t.enabled);
@@ -48,16 +77,21 @@ async function executeCall(req){
48
77
  const prev=seen.get(req.requestId);
49
78
  if(prev)return prev.payload===argsKey?prev.promise:bad(req,'REQUEST_ID_REUSE');
50
79
  const expires=Date.now()+req.deadlineMs;
80
+ saveReceipt(req.requestId,'RUNNING');
51
81
  const promise=serial(tool.id,async()=>{
52
82
  try{
53
83
  const adapter=adapterFor(tool);
54
84
  if(typeof adapter.call!=='function')return bad(req,'TOOL_UNAVAILABLE');
55
85
  const value=await adapter.call(tool,req.name,req.args,
56
- {requestId:req.requestId,remaining:()=>Math.max(0,expires-Date.now())});
86
+ {requestId:req.requestId,remaining:()=>Math.max(0,expires-Date.now()),
87
+ onLate:(error,value)=>saveReceipt(req.requestId,error?'FAILED':'COMPLETED',error?null:value,
88
+ error?.code||null)});
89
+ saveReceipt(req.requestId,'COMPLETED',value);
57
90
  log('tool-call',{requestId:req.requestId,toolId:tool.id,toolName:req.name,status:'OK'});
58
91
  return {contract:CONTRACT,requestId:req.requestId,status:'OK',result:value};
59
92
  }catch(e){
60
93
  const code=e.code||'UNKNOWN_RESULT';
94
+ saveReceipt(req.requestId,code==='TIMEOUT'?'UNCONFIRMED':'FAILED',null,code);
61
95
  log('tool-call',{requestId:req.requestId,toolId:tool.id,toolName:req.name,status:'FAILED',errorCode:code});
62
96
  return bad(req,code);
63
97
  }
@@ -93,11 +127,17 @@ async function execute(req){
93
127
  outcome:'OBSERVED',pid:null,evidence:'REGISTRY',error:null};
94
128
  return adapterFor(t).inspect(t,ctx);
95
129
  }
96
- if(req.action==='start')return adapterFor(t).launch(t,ctx);
130
+ if(req.action==='start'){
131
+ const began=await adapterFor(t).launch(t,ctx);
132
+ if(t.id==='tunnel'&&began.after==='RUNNING')desiredTunnel.add(t.id);
133
+ return began;
134
+ }
135
+ if(t.id==='tunnel')desiredTunnel.delete(t.id);
97
136
  if(req.action==='stop')return adapterFor(t).stop(t,ctx);
98
137
  const ended=await adapterFor(t).stop(t,ctx);
99
138
  if(ended.error)return {...ended,outcome:'RESTART_FAILED'};
100
139
  const began=await adapterFor(t).launch(t,ctx);
140
+ if(t.id==='tunnel'&&began.after==='RUNNING')desiredTunnel.add(t.id);
101
141
  return {...began,before:ended.before,outcome:began.error?'RESTART_FAILED':'RESTARTED'};
102
142
  }).catch(e=>({toolId:t.id,enabled:t.enabled,before:'UNKNOWN',after:'UNKNOWN',
103
143
  outcome:'ADAPTER_FAILED',pid:null,evidence:'UNHANDLED_ADAPTER_ERROR',
@@ -105,7 +145,10 @@ async function execute(req){
105
145
  results.push(item);
106
146
  }
107
147
  const failures=results.filter(r=>r.error);
108
- const status=results.some(r=>r.after==='UNKNOWN')?'UNKNOWN':failures.length===0?'OK':failures.length===results.length?'FAILED':'PARTIAL';
148
+ const status=results.some(r=>r.after==='UNKNOWN')?'UNKNOWN':
149
+ results.some(r=>r.after==='DEGRADED')?'DEGRADED':
150
+ results.some(r=>r.after==='STARTING')?'STARTING':
151
+ failures.length===0?'OK':failures.length===results.length?'FAILED':'PARTIAL';
109
152
  const response={contract:CONTRACT,requestId:req.requestId,action:req.action,scope:req.scope,target:req.toolId||null,status,results,managerPid:process.pid};
110
153
  log('control',{requestId:req.requestId,action:req.action,scope:req.scope,toolId:req.toolId||null,status,
111
154
  results:results.map(r=>({toolId:r.toolId,after:r.after,outcome:r.outcome,errorCode:r.error?.code||null}))});
@@ -147,7 +190,7 @@ async function main(){
147
190
  handled=true;
148
191
  let req;try{req=JSON.parse(buf.slice(0,n));}catch{socket.end(JSON.stringify(bad({},'INVALID_ARGUMENT'))+'\n');return;}
149
192
  const work=req?.action==='__ping'?
150
- Promise.resolve({contract:CONTRACT,version:VERSION,registrySignature:catalog.map(t=>t.id+':'+t.enabled).join('|'),ownerNonce:nonce,managerPid:process.pid}):req?.action==='call'?executeCall(req):execute(req);
193
+ Promise.resolve({contract:CONTRACT,version:VERSION,packageVersion:PACKAGE_VERSION,registrySignature:catalog.map(t=>t.id+':'+t.enabled).join('|'),ownerNonce:nonce,managerPid:process.pid}):req?.action==='call'?executeCall(req):req?.action==='receipt'?Promise.resolve(receipt(req)):execute(req);
151
194
  work.then(value=>socket.end(JSON.stringify(value)+'\n')).catch(err=>{
152
195
  log('ipc-error',{code:err.code||'INTERNAL_ERROR'});
153
196
  socket.end(JSON.stringify(bad(req,'INTERNAL_ERROR'))+'\n');
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.13",
6
+ "version": "0.4.15",
7
7
  "type": "module",
8
8
  "description": "Chat Loop local tools: MCP gateway, isolated bundled tunnel-client and unified CLI",
9
9
  "bin": {