@quantiya/codevibe-claude-plugin 1.0.35 → 1.0.37

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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/server.js +13 -13
  3. package/hooks/permission-request.sh +32 -2
  4. package/hooks/stop.sh +22 -2
  5. package/node_modules/@quantiya/codevibe-core/dist/appsync/__tests__/appsync-client.test.d.ts +1 -0
  6. package/node_modules/@quantiya/codevibe-core/dist/appsync/appsync-client.d.ts +139 -1
  7. package/node_modules/@quantiya/codevibe-core/dist/appsync/queries.d.ts +5 -0
  8. package/node_modules/@quantiya/codevibe-core/dist/audit-keys/__tests__/audit-keys-parity.test.d.ts +1 -0
  9. package/node_modules/@quantiya/codevibe-core/dist/audit-keys/index.d.ts +41 -0
  10. package/node_modules/@quantiya/codevibe-core/dist/auth/__tests__/auth-telemetry.test.d.ts +1 -0
  11. package/node_modules/@quantiya/codevibe-core/dist/auth/auth-telemetry.d.ts +29 -8
  12. package/node_modules/@quantiya/codevibe-core/dist/index.d.ts +4 -0
  13. package/node_modules/@quantiya/codevibe-core/dist/index.js +194 -33
  14. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-bootstrap.test.d.ts +1 -0
  15. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-failure-recourse.test.d.ts +1 -0
  16. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-save.test.d.ts +1 -0
  17. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-seat-picker.test.d.ts +1 -0
  18. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-telemetry.test.d.ts +1 -0
  19. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-test-agents.test.d.ts +1 -0
  20. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-types.test.d.ts +1 -0
  21. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/setup-wizard.test.d.ts +1 -0
  22. package/node_modules/@quantiya/codevibe-core/dist/orchestration/__tests__/v1-options.test.d.ts +1 -0
  23. package/node_modules/@quantiya/codevibe-core/dist/orchestration/detect-agents.d.ts +56 -0
  24. package/node_modules/@quantiya/codevibe-core/dist/orchestration/index.d.ts +3 -0
  25. package/node_modules/@quantiya/codevibe-core/dist/orchestration/orchestration-cli.d.ts +12 -0
  26. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-bootstrap.d.ts +146 -0
  27. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-failure-recourse.d.ts +23 -0
  28. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-save.d.ts +47 -0
  29. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-seat-picker.d.ts +72 -0
  30. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-telemetry.d.ts +54 -0
  31. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-test-agents.d.ts +108 -0
  32. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-types.d.ts +140 -0
  33. package/node_modules/@quantiya/codevibe-core/dist/orchestration/setup-wizard.d.ts +57 -0
  34. package/node_modules/@quantiya/codevibe-core/dist/orchestration/v1-options.d.ts +108 -0
  35. package/node_modules/@quantiya/codevibe-core/dist/reviewer/__tests__/integration.test.d.ts +1 -0
  36. package/node_modules/@quantiya/codevibe-core/dist/reviewer/__tests__/mocks.test.d.ts +1 -0
  37. package/node_modules/@quantiya/codevibe-core/dist/reviewer/__tests__/output-parser.test.d.ts +1 -0
  38. package/node_modules/@quantiya/codevibe-core/dist/reviewer/__tests__/registry.test.d.ts +1 -0
  39. package/node_modules/@quantiya/codevibe-core/dist/reviewer/__tests__/subprocess.test.d.ts +1 -0
  40. package/node_modules/@quantiya/codevibe-core/dist/reviewer/index.d.ts +15 -0
  41. package/node_modules/@quantiya/codevibe-core/dist/reviewer/mocks.d.ts +80 -0
  42. package/node_modules/@quantiya/codevibe-core/dist/reviewer/output-parser.d.ts +95 -0
  43. package/node_modules/@quantiya/codevibe-core/dist/reviewer/provider.d.ts +153 -0
  44. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/__tests__/claude-live-smoke.test.d.ts +1 -0
  45. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/__tests__/claude.test.d.ts +1 -0
  46. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/__tests__/codex-live-smoke.test.d.ts +1 -0
  47. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/__tests__/codex.test.d.ts +1 -0
  48. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/__tests__/gemini-live-smoke.test.d.ts +1 -0
  49. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/__tests__/gemini.test.d.ts +1 -0
  50. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/claude.d.ts +59 -0
  51. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/codex.d.ts +67 -0
  52. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/common.d.ts +25 -0
  53. package/node_modules/@quantiya/codevibe-core/dist/reviewer/providers/gemini.d.ts +108 -0
  54. package/node_modules/@quantiya/codevibe-core/dist/reviewer/registry.d.ts +87 -0
  55. package/node_modules/@quantiya/codevibe-core/dist/reviewer/subprocess.d.ts +117 -0
  56. package/node_modules/@quantiya/codevibe-core/dist/reviewer/types.d.ts +101 -0
  57. package/node_modules/@quantiya/codevibe-core/dist/types/index.d.ts +2 -0
  58. package/node_modules/@quantiya/codevibe-core/dist/types/orchestration.d.ts +57 -0
  59. package/node_modules/@quantiya/codevibe-core/dist/types/reviewer.d.ts +67 -0
  60. package/node_modules/@quantiya/codevibe-core/dist/types/session.d.ts +16 -0
  61. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codevibe-claude",
3
- "version": "1.0.35",
3
+ "version": "1.0.37",
4
4
  "description": "Sync Claude Code sessions with iOS mobile app via AWS backend. Control Claude Code from your phone with real-time bidirectional synchronization.",
5
5
  "author": {
6
6
  "name": "CodeVibe Team"
package/dist/server.js CHANGED
@@ -1,17 +1,17 @@
1
- "use strict";var ee=Object.create;var k=Object.defineProperty;var te=Object.getOwnPropertyDescriptor;var se=Object.getOwnPropertyNames;var ne=Object.getPrototypeOf,ie=Object.prototype.hasOwnProperty;var oe=(l,e)=>{for(var s in e)k(l,s,{get:e[s],enumerable:!0})},U=(l,e,s,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of se(e))!ie.call(l,i)&&i!==s&&k(l,i,{get:()=>e[i],enumerable:!(t=te(e,i))||t.enumerable});return l};var S=(l,e,s)=>(s=l!=null?ee(ne(l)):{},U(e||!l||!l.__esModule?k(s,"default",{value:l,enumerable:!0}):s,l)),re=l=>U(k({},"__esModule",{value:!0}),l);var le={};oe(le,{parseInteractivePromptInput:()=>J});module.exports=re(le);var E=S(require("fs")),w=S(require("path")),_=S(require("os")),Y=require("child_process"),z=require("util"),Q=require("child_process");var K=S(require("os")),L=S(require("path")),j=require("@quantiya/codevibe-core"),n=(0,j.createLogger)({name:"codevibe-claude",logFile:L.default.join(K.default.tmpdir(),"codevibe-claude-mcp.log"),level:"info"});var a=require("@quantiya/codevibe-core");var O=S(require("express")),b=S(require("fs")),D=S(require("path")),N=S(require("os")),V=require("@quantiya/codevibe-core");var g=require("@quantiya/codevibe-core");var R=class{constructor(){this.assignedPort=0;this.app=(0,O.default)(),this.setupMiddleware(),this.setupRoutes()}setSessionId(e){this.sessionId=e}getPort(){return this.assignedPort}setupMiddleware(){this.app.use(O.default.json({limit:"1mb"})),this.app.use((e,s,t)=>{n.debug(`${e.method} ${e.path}`,{body:e.body,query:e.query}),t()}),this.app.use((e,s,t,i)=>{n.error("Express error:",e);let o={success:!1,error:e.message||"Internal server error"};t.status(500).json(o)})}setupRoutes(){this.app.get("/health",this.handleHealth.bind(this)),this.app.post("/event",this.handleEvent.bind(this)),process.env.NODE_ENV!=="production"&&this.app.post("/test/execute",this.handleTestExecute.bind(this))}handleHealth(e,s){let t={success:!0,data:{status:"healthy",uptime:process.uptime(),version:"0.1.0",timestamp:new Date().toISOString()}};s.json(t)}async handleEvent(e,s){try{let t=e.body;if(!t.session_id){let r={success:!1,error:"Missing required field: session_id"};s.status(400).json(r);return}if(!t.hook_event_name){let r={success:!1,error:"Missing required field: hook_event_name"};s.status(400).json(r);return}let i=this.transformHookToEvent(t);n.info("Received event from hook",{sessionId:t.session_id,hookEvent:t.hook_event_name,type:i.type}),this.eventHandler?await this.eventHandler(i):n.warn("No event handler registered");let o={success:!0,message:"Event processed successfully"};s.json(o)}catch(t){n.error("Error handling event:",t);let i={success:!1,error:t instanceof Error?t.message:"Unknown error"};s.status(500).json(i)}}async handleTestExecute(e,s){try{let{sessionId:t,prompt:i}=e.body;if(!t||!i){let r={success:!1,error:"Missing required fields: sessionId, prompt"};s.status(400).json(r);return}n.info("Test execute request",{sessionId:t,prompt:i});let o={success:!0,message:"Test execution endpoint - not implemented yet",data:{sessionId:t,prompt:i}};s.json(o)}catch(t){n.error("Error in test execute:",t);let i={success:!1,error:t instanceof Error?t.message:"Unknown error"};s.status(500).json(i)}}transformHookToEvent(e){let s,t,i={cwd:e.cwd,hook_event_name:e.hook_event_name,...e.metadata||{}};if(e.type&&e.content!==void 0)s=e.type,t=e.content;else switch(e.hook_event_name){case"SessionStart":s=g.EventType.NOTIFICATION,t="Session started",i.source=e.source;break;case"SessionEnd":s=g.EventType.NOTIFICATION,t=`Session ended: ${e.reason||"unknown"}`,i.reason=e.reason;break;case"UserPromptSubmit":s=g.EventType.USER_PROMPT,t=e.prompt||"";break;case"PostToolUse":s=g.EventType.TOOL_USE,t=JSON.stringify({tool_name:e.tool_name,tool_input:e.tool_input,tool_response:e.tool_response}),i.tool_name=e.tool_name;break;case"Notification":s=g.EventType.NOTIFICATION,t=e.message||"",i.notification_type=e.notification_type;break;default:s=g.EventType.NOTIFICATION,t=`Hook event: ${e.hook_event_name}`}return{session_id:e.session_id,hook_event_name:e.hook_event_name,type:s,source:g.EventSource.DESKTOP,content:t,metadata:i}}onEvent(e){this.eventHandler=e}async start(e){let s=e||this.sessionId;return s&&(this.sessionId=s),new Promise((t,i)=>{try{let o=(0,V.getConfig)(),r=o.server.dynamicPort?0:o.server.port;this.server=this.app.listen(r,o.server.host,()=>{let c=this.server.address();this.assignedPort=c.port,n.info(`HTTP API listening on http://${o.server.host}:${this.assignedPort}`),this.sessionId&&this.writePortFile(this.sessionId,this.assignedPort),t(this.assignedPort)}),this.server.on("error",c=>{n.error("HTTP server error:",c),i(c)})}catch(o){i(o)}})}writePortFile(e,s){let t=D.join(N.tmpdir(),`codevibe-claude-${e}.port`);try{b.writeFileSync(t,s.toString()),n.info(`Port file written: ${t} -> ${s}`)}catch(i){n.error(`Failed to write port file: ${t}`,i)}}removePortFile(){if(this.sessionId){let e=D.join(N.tmpdir(),`codevibe-claude-${this.sessionId}.port`);try{b.existsSync(e)&&(b.unlinkSync(e),n.info(`Port file removed: ${e}`))}catch(s){n.warn(`Failed to remove port file: ${e}`,s)}}}async stop(e){return new Promise((s,t)=>{this.sessionId&&e?.protectedSessionIds?.has(this.sessionId)?n.info("Skipping port file removal \u2014 another daemon still serves this session",{sessionId:this.sessionId}):this.removePortFile(),this.server?this.server.close(i=>{i?(n.error("Error stopping HTTP server:",i),t(i)):(n.info("HTTP API stopped"),s())}):s()})}};var B=require("child_process"),H=require("@quantiya/codevibe-core");var M=class{async executePrompt(e,s){let t=(0,H.getConfig)(),i=t.claude.defaultTimeout;return n.info("Executing prompt from mobile",{sessionId:e,promptLength:s.length,timeout:i}),new Promise(o=>{let r=["--resume",e,"--print","--output-format","stream-json",s];n.debug("Spawning Claude command",{command:t.claude.command,args:r});let c=(0,B.spawn)(t.claude.command,r,{stdio:["pipe","pipe","pipe"],shell:!0}),d="",p="",m=!1,h=setTimeout(()=>{m=!0,n.warn("Command execution timed out",{sessionId:e,timeout:i}),c.kill("SIGTERM")},i);c.stdout?.on("data",f=>{let u=f.toString();d+=u,n.debug("Command stdout",{output:u.slice(0,200)})}),c.stderr?.on("data",f=>{let u=f.toString();p+=u,n.debug("Command stderr",{output:u.slice(0,200)})}),c.on("close",f=>{clearTimeout(h);let u={success:f===0&&!m,output:d,error:p,exitCode:f||void 0,timedOut:m};u.success?n.info("Command executed successfully",{sessionId:e,exitCode:f,outputLength:d.length}):n.error("Command execution failed",{sessionId:e,exitCode:f,timedOut:m,error:p.slice(0,500)}),o(u)}),c.on("error",f=>{clearTimeout(h),n.error("Failed to spawn command",{error:f.message}),o({success:!1,error:f.message,timedOut:!1})})})}detectInteractivePrompt(e){return[/\[Y\/n\]/i,/\[y\/N\]/i,/\(y\/n\)/i,/Continue\?/i,/Proceed\?/i].some(t=>t.test(e))}extractPromptText(e){let s=e.split(`
2
- `);for(let t=s.length-1;t>=0;t--){let i=s[t].trim();if(this.detectInteractivePrompt(i))return i}return null}};var W=require("child_process"),X=require("util");var q=(0,X.promisify)(W.exec),A=class{async answerInteractivePrompt(e,s){n.info("Attempting to answer interactive prompt",{sessionId:e,response:s});try{let t=process.env.CODEVIBE_TMUX_SESSION;return n.info("Checking tmux session environment",{tmuxSession:t||"(not set)",allEnvKeys:Object.keys(process.env).filter(i=>i.includes("CODEVIBE")||i.includes("TMUX"))}),t?(n.info("Using tmux send-keys",{tmuxSession:t}),await this.sendViaTmux(t,s),n.info("Successfully sent response to interactive prompt",{sessionId:e,response:s}),!0):(n.error("No tmux session found - codevibe-claude wrapper is required",{sessionId:e,hint:"Start Claude Code using the codevibe-claude wrapper script"}),!1)}catch(t){return n.error("Failed to answer interactive prompt",{sessionId:e,error:t instanceof Error?t.message:String(t)}),!1}}async sendViaTmux(e,s){let t=s.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\$/g,"\\$").replace(/`/g,"\\`");n.info("Sending via tmux",{sessionName:e,inputLength:s.length});try{let i=`tmux send-keys -t "${e}" -l "${t}"`,o=await q(i);n.info("tmux send-keys (text) completed",{stdout:o.stdout||"(empty)",stderr:o.stderr||"(empty)"}),await this.delay(500);let r=`tmux send-keys -t "${e}" Enter`,c=await q(r);n.info("tmux send-keys (Enter) completed",{stdout:c.stdout||"(empty)",stderr:c.stderr||"(empty)"})}catch(i){throw n.error("tmux send-keys failed",{sessionName:e,error:i}),i}}delay(e){return new Promise(s=>setTimeout(s,e))}isPromptResponse(e){let s=e.trim().toLowerCase();return!!(s==="y"||s==="n"||s==="yes"||s==="no"||/^[0-9]+$/.test(s)||/^[a-z]$/.test(s)||["exit","quit","q","continue","skip","abort","retry","cancel"].includes(s))}};var ae=(0,z.promisify)(Q.exec),ce="/exit",G="CODEVIBE_TMUX_SESSION";async function pe(l,e){let s=async(t,i)=>{try{await ae(t)}catch(o){n.warn("tmux send-keys failed during self-terminate",{sessionName:l,label:i,error:String(o)})}};await s(`tmux send-keys -t "${l}" C-c`,"ctrl-c"),await new Promise(t=>setTimeout(t,200)),await s(`tmux send-keys -t "${l}" -l "${e}"`,"quit-text"),await new Promise(t=>setTimeout(t,500)),await s(`tmux send-keys -t "${l}" Enter`,"enter")}var $=class l{constructor(e){this.activeSessions=new Map;this.assignedPort=0;this.sessionKey=null;this.claudeToBackendSessionId=new Map;this.pendingMobilePrompts=new Map;this.httpApi=new R,this.commandExecutor=new M,this.promptResponder=new A,this.initialSessionId=e}static{this.MOBILE_PROMPT_EXPIRY_MS=3e3}getPort(){return this.assignedPort}generateBackendSessionId(e){return`claude-${e}`}trackMobilePrompt(e,s){this.pendingMobilePrompts.has(e)||this.pendingMobilePrompts.set(e,[]),this.pendingMobilePrompts.get(e).push({prompt:s.trim(),timestamp:Date.now()}),n.debug("Tracking mobile prompt for deduplication",{sessionId:e,promptLength:s.length})}isRecentMobilePrompt(e,s){let t=this.pendingMobilePrompts.get(e);if(!t)return!1;let i=Date.now(),o=s.trim(),r=[],c=!1;for(let d of t)if(!(i-d.timestamp>l.MOBILE_PROMPT_EXPIRY_MS)){if(!c&&d.prompt===o){c=!0,n.debug("Found matching mobile prompt, filtering duplicate",{sessionId:e});continue}r.push(d)}return r.length>0?this.pendingMobilePrompts.set(e,r):this.pendingMobilePrompts.delete(e),c}writePortFile(e){let s=w.join(_.tmpdir(),`codevibe-claude-${e}.port`);try{E.writeFileSync(s,this.assignedPort.toString()),n.info(`Port file written: ${s} -> ${this.assignedPort}`)}catch(t){n.error(`Failed to write port file: ${s}`,t)}}removePortFile(e){let s=w.join(_.tmpdir(),`codevibe-claude-${e}.port`);try{E.existsSync(s)&&(E.unlinkSync(s),n.info(`Port file removed: ${s}`))}catch(t){n.warn(`Failed to remove port file: ${s}`,t)}}hasOtherLiveDaemonForSession(e){try{let s=(0,Y.execSync)("ps -eww -o pid= -o args=",{encoding:"utf8",timeout:2e3}),t=process.pid;for(let i of s.split(`
3
- `)){let o=i.trim();if(!o)continue;let r=o.indexOf(" ");if(r<0)continue;let c=parseInt(o.substring(0,r),10);if(isNaN(c)||c===t)continue;let d=o.substring(r+1);if(/node.*codevibe-claude.*server\.js/.test(d)&&d.includes(e))return!0}return!1}catch(s){return n.warn('hasOtherLiveDaemonForSession: ps query failed; falling back to "no other daemon"',{error:String(s)}),!1}}async start(){try{if(n.info("Starting CodeVibe MCP Server...",{environment:(0,a.getEnvironment)()}),this.appSyncClient=new a.AppSyncClient,await this.appSyncClient.authenticateWithStoredTokens()){n.info("Authenticated with stored OAuth tokens",{userId:this.appSyncClient.getCurrentUserId(),email:this.appSyncClient.getCurrentUserEmail()}),await(0,a.registerDeviceEncryptionKey)(this.appSyncClient,n),(0,a.startDeviceKeyWatcher)(this.appSyncClient,n);try{let s=await this.appSyncClient.sweepOrphanSessions({agentType:"CLAUDE"});s>0&&n.info("Orphan sweep: marked stale Claude sessions INACTIVE",{swept:s})}catch(s){n.warn("Orphan sweep failed, continuing startup",{error:s instanceof Error?s.message:String(s)})}}else n.error('Authentication failed. Run "codevibe-claude login" first.'),console.error('Not authenticated. Run "codevibe-claude login" to sign in.'),process.exit(1);this.httpApi.onEvent(this.handleEventFromHook.bind(this)),this.assignedPort=await this.httpApi.start(this.initialSessionId),n.info("MCP Server started successfully",{port:this.assignedPort,host:(0,a.getConfig)().server.host,dynamicPort:(0,a.getConfig)().server.dynamicPort,sessionId:this.initialSessionId,authenticated:this.appSyncClient.isAuthenticated(),userId:this.appSyncClient.getCurrentUserId()})}catch(e){throw n.error("Failed to start MCP Server:",e),e}}async stop(){n.info("Stopping MCP Server...");let e=Array.from(this.activeSessions.keys()),s=new Set;n.info(`Marking ${e.length} active session(s) as INACTIVE...`);for(let t of e){let i=this.activeSessions.get(t);i?.mobileEndWatcher&&(i.mobileEndWatcher.stop(),i.mobileEndWatcher=void 0)}for(let t of e)try{let i=this.activeSessions.get(t);if(i&&this.hasOtherLiveDaemonForSession(i.claudeSessionId)){n.info("Another daemon serves this session \u2014 skipping mark INACTIVE AND port file removal during shutdown",{sessionId:t,claudeSessionId:i.claudeSessionId,myPid:process.pid}),s.add(i.claudeSessionId);continue}await this.appSyncClient.updateSession({sessionId:t,status:a.SessionStatus.INACTIVE}),n.info("Session marked as INACTIVE during shutdown",{sessionId:t}),i&&this.removePortFile(i.claudeSessionId)}catch(i){n.warn("Failed to mark session as INACTIVE during shutdown",{sessionId:t,error:i})}this.appSyncClient.cleanupSubscriptions(),this.activeSessions.clear(),await this.httpApi.stop({protectedSessionIds:s}),n.info("MCP Server stopped")}async handleEventFromHook(e){let{session_id:s,hook_event_name:t,type:i,content:o}=e;n.info("Processing hook event",{sessionId:s,hookEvent:t,type:i});try{t==="SessionStart"?await this.handleSessionStart(e):t==="SessionEnd"&&await this.handleSessionEnd(e);let r=this.claudeToBackendSessionId.get(s)||this.generateBackendSessionId(s);if(i===a.EventType.USER_PROMPT&&e.source===a.EventSource.DESKTOP&&t==="UserPromptSubmit"&&o&&this.isRecentMobilePrompt(r,o)){n.info("Skipping duplicate USER_PROMPT from mobile-originated prompt",{sessionId:r,contentLength:o.length});return}if(i===a.EventType.INTERACTIVE_PROMPT){let h=this.activeSessions.get(r);h&&(h.waitingForPromptResponse=!0,h.pendingPromptId=e.prompt_id,n.info("Interactive prompt detected - will parse options from tmux",{sessionId:r,promptId:e.prompt_id})),this.sendInteractivePromptAsync(r,e,o).catch(f=>{n.error("Failed to send interactive prompt with dynamic options",{error:f})});return}let c=o,d=e.metadata,p=!1;n.info("Hook event encryption state",{type:i,sessionId:r,hasSessionKey:!!this.sessionKey,sessionKeyLength:this.sessionKey?.length||0}),this.sessionKey?(c=a.cryptoService.encryptContent(o,this.sessionKey),d&&(d={encrypted:a.cryptoService.encryptMetadata(d,this.sessionKey)}),p=!0,n.info("Event encrypted for hook",{type:i,sessionId:r,isEncrypted:!0})):n.warn("No session key - event will NOT be encrypted",{type:i,sessionId:r});let m=await this.appSyncClient.createEvent({sessionId:r,type:i,source:e.source,content:c,metadata:d,promptId:e.prompt_id,isEncrypted:p?!0:void 0});if(i===a.EventType.USER_PROMPT&&e.source===a.EventSource.DESKTOP){let h=this.activeSessions.get(r);h?.waitingForPromptResponse&&(h.waitingForPromptResponse=!1,h.pendingPromptId=void 0,h.pendingSubmitMap=void 0,n.info("Clearing prompt wait state - new desktop prompt received",{sessionId:r}))}n.debug("Event sent to AppSync successfully")}catch(r){throw n.error("Failed to process hook event:",r),r}}async handleSessionStart(e){let s=e.session_id,t=this.generateBackendSessionId(s),i=e.metadata?.cwd||process.cwd();this.claudeToBackendSessionId.set(s,t),n.info("Session started",{claudeSessionId:s,sessionId:t,cwd:i});let o=Array.from(this.activeSessions.keys()).filter(p=>p!==t);if(o.length>0){n.info(`Marking ${o.length} previous session(s) as INACTIVE`);for(let p of o){let m=this.activeSessions.get(p);m?.mobileEndWatcher&&(m.mobileEndWatcher.stop(),m.mobileEndWatcher=void 0);try{await this.appSyncClient.updateSession({sessionId:p,status:a.SessionStatus.INACTIVE}),n.info("Previous session marked INACTIVE",{prevId:p,newSessionId:t})}catch(h){n.warn("Failed to mark previous session as INACTIVE",{prevId:p,error:h})}m&&this.removePortFile(m.claudeSessionId),this.activeSessions.delete(p)}}this.writePortFile(s);let r=this.appSyncClient.getCurrentUserId(),c={sessionId:t,claudeSessionId:s,userId:r,projectPath:i,cwd:i,createdAt:new Date,subscriptionActive:!1,waitingForPromptResponse:!1,metadata:e.metadata||{}};this.activeSessions.set(t,c);try{let p=await(0,a.resumeOrCreateSession)({sessionId:t,userId:c.userId,agentType:a.AgentType.CLAUDE,projectPath:i,metadata:e.metadata||{}},this.appSyncClient,n);if(this.sessionKey=p.sessionKey,p.resumed&&!p.sessionKey){let m=await a.keychainManager.getDeviceId();n.error("Device key not found in session encryptedKeys",{sessionId:t,pluginDeviceId:m}),console.error(`
4
- \u26A0\uFE0F E2E ENCRYPTION WARNING: Cannot decrypt this session!`),console.error(` Your device ID (${m.substring(0,8)}...) is not in session's encryption keys.`),console.error(" This happens if your device key was regenerated after the session was created."),console.error(` SOLUTION: Start a new Claude Code session instead of resuming this one.
5
- `)}}catch(p){if(this.isSessionLimitExceeded(p)){this.displaySubscriptionLimitError(p,"session"),this.activeSessions.delete(t),this.removePortFile(s);return}n.error("Failed to create/resume session:",p)}this.subscribeToMobileEvents(t),this.appSyncClient.startHeartbeat(t);let d=this.activeSessions.get(t);d&&(d.mobileEndWatcher=this.appSyncClient.watchForMobileEnd(t,async()=>{n.info("Mobile ended session \u2014 sending desktop quit",{sessionId:t});let p=process.env[G];if(!p){n.warn("No tmux session set; skipping desktop self-terminate",{sessionId:t,expectedEnv:G});return}await pe(p,ce)}))}async handleSessionEnd(e){let s=e.session_id,t=this.claudeToBackendSessionId.get(s)||this.generateBackendSessionId(s);n.info("Session ended",{claudeSessionId:s,sessionId:t,reason:e.metadata?.reason});let i=this.activeSessions.get(t);if(i?.mobileEndWatcher&&(i.mobileEndWatcher.stop(),i.mobileEndWatcher=void 0),this.removePortFile(s),i?.waitingForPromptResponse&&(n.info("Clearing prompt wait state - session ending",{sessionId:t}),i.waitingForPromptResponse=!1,i.pendingPromptId=void 0),this.appSyncClient.stopHeartbeat(t),i)try{await this.appSyncClient.updateSession({sessionId:t,status:a.SessionStatus.INACTIVE}),n.info("Session marked as INACTIVE in AppSync",{sessionId:t})}catch(o){n.warn("Failed to update session in AppSync:",o)}else n.warn("Cannot update session - session state not found",{sessionId:t});this.activeSessions.delete(t),this.claudeToBackendSessionId.delete(s),n.debug("Session cleanup completed",{sessionId:t})}subscribeToMobileEvents(e){n.info("Subscribing to mobile events",{sessionId:e});let s=this.activeSessions.get(e);if(!s){n.error("Session not found",{sessionId:e});return}this.appSyncClient.subscribeToEvents(e,async t=>{n.info("Received mobile event",{eventId:t.eventId,type:t.type,sessionId:t.sessionId,isEncrypted:t.isEncrypted});let i=t.content||"";if(t.isEncrypted&&this.sessionKey)try{i=a.cryptoService.decryptContent(t.content,this.sessionKey),n.debug("Event decrypted successfully",{eventId:t.eventId})}catch(r){n.error("Failed to decrypt event:",{eventId:t.eventId,error:r}),i=t.content}let o={...t,content:i};try{await this.appSyncClient.updateEventStatus({eventId:t.eventId,sessionId:t.sessionId,timestamp:t.timestamp,deliveryStatus:a.DeliveryStatus.DELIVERED}),n.info("Event marked as DELIVERED",{eventId:t.eventId})}catch(r){n.warn("Failed to mark event as DELIVERED",{eventId:t.eventId,error:r})}if(t.type===a.EventType.USER_PROMPT){let r=this.activeSessions.get(e);if(r?.waitingForPromptResponse){let c=i.trim(),d=r.pendingSubmitMap?Object.keys(r.pendingSubmitMap).length:3,p=this.parseInteractivePromptInput(c,d);if(n.info("Parsed interactive prompt input",{sessionId:e,content:c,parsed:p,hasSubmitMap:!!r.pendingSubmitMap}),p.action==="select_option"){let m=r.pendingSubmitMap?.[p.option]||p.option;n.info("User selected option",{option:p.option,terminalInput:m}),await this.promptResponder.answerInteractivePrompt(e,m)?(await this.markEventExecuted(t),r.waitingForPromptResponse=!1,r.pendingPromptId=void 0,r.pendingSubmitMap=void 0,await this.appSyncClient.createEvent({sessionId:e,type:a.EventType.NOTIFICATION,source:a.EventSource.DESKTOP,content:`Selected option ${p.option}`,metadata:{promptAnswered:!0}})):await this.sendPromptError(e,"Failed to select option")}else if(p.action==="option_with_followup"){let m=r.pendingSubmitMap?.[p.option]||p.option;n.info("User selected option with follow-up",{option:p.option,terminalInput:m,followUpText:p.followUpText});let h=await this.promptResponder.answerInteractivePrompt(e,m);if(r.waitingForPromptResponse=!1,r.pendingPromptId=void 0,r.pendingSubmitMap=void 0,h){if(await this.appSyncClient.createEvent({sessionId:e,type:a.EventType.NOTIFICATION,source:a.EventSource.DESKTOP,content:`Selected option ${p.option}`,metadata:{promptAnswered:!0}}),p.followUpText){await new Promise(u=>setTimeout(u,1e3));let f={...t,content:p.followUpText};await this.executeMobilePrompt(e,f)}await this.markEventExecuted(t)}else await this.sendPromptError(e,"Failed to select option")}else n.info("Sending as free-form response to interactive prompt",{response:c}),await this.promptResponder.answerInteractivePrompt(e,c)?(await this.markEventExecuted(t),r.waitingForPromptResponse=!1,r.pendingPromptId=void 0,r.pendingSubmitMap=void 0,await this.appSyncClient.createEvent({sessionId:e,type:a.EventType.NOTIFICATION,source:a.EventSource.DESKTOP,content:"Response sent to interactive prompt",metadata:{promptAnswered:!0}})):await this.sendPromptError(e,"Failed to send response")}else await this.executeMobilePrompt(e,o)}},t=>{n.error("Subscription error",{sessionId:e,error:t})}),s.subscriptionActive=!0,n.info("Subscription active",{sessionId:e})}async sendInteractivePromptAsync(e,s,t){await new Promise(u=>setTimeout(u,500));let i=process.env.CODEVIBE_TMUX_SESSION,o={...s.metadata||{}},r=s.metadata?.tool_name,c=s.metadata?.tool_input,d=r==="AskUserQuestion"?c?.questions?.[0]:null;if(d&&Array.isArray(d.options)&&d.options.length>0){let u=d.options.map((v,y)=>{let P=typeof v=="string",F=P?v:v.label||"",x=P?"":v.description||"",C={number:String(y+1),text:F};return x&&(C.description=x),C}),I=String(u.length+1),T=String(u.length+2);u.push({number:I,text:"Type something"},{number:T,text:"Chat about this"}),o.options=u,o.submitMap=Object.fromEntries(u.map(v=>[v.number,v.number])),o.instructions=d.multiSelect?`Reply with comma-separated numbers (e.g., 1,3) for "${d.header||d.question}"`:`Reply with the number of your choice. For option ${I} (Type something), reply "${I}, your answer".`,t=d.question,n.info("AskUserQuestion: using tool_input directly (skipped tmux parse)",{questionPreview:d.question.slice(0,80),optionCount:u.length,multiSelect:!!d.multiSelect})}else if(i)try{let{exec:u}=await import("child_process"),I=P=>new Promise((F,x)=>{u(P,{timeout:5e3},(C,Z)=>{C?x(C):F({stdout:Z||""})})}),{stdout:T}=await I(`tmux capture-pane -p -e -S -30 -t '${i}'`),v=T.split(`
6
- `);n.info("tmux capture result",{tmuxSession:i,totalLines:v.length,lastLines:v.slice(-15).map(P=>P.replace(/\x1B[^m]*m/g,"").trim()).filter(Boolean)});let y=(0,a.parseInteractivePrompt)(T);y&&y.options.length>0?(o.options=y.options,o.submitMap=y.submitMap,o.instructions=this.buildPromptInstructions(y),n.info("Parsed dynamic options from tmux",{optionCount:y.options.length,kind:y.kind,options:y.options})):(n.info("No dynamic options parsed from tmux, using fallback",{parsedResult:y}),this.addFallbackOptions(o))}catch(u){n.warn("Failed to capture tmux pane for options",{error:u}),this.addFallbackOptions(o)}else n.warn("No tmux session \u2014 using fallback options"),this.addFallbackOptions(o);let p=this.activeSessions.get(e);p&&o.submitMap&&(p.pendingSubmitMap=o.submitMap);let m=t,h=o,f=!1;this.sessionKey&&(m=a.cryptoService.encryptContent(t,this.sessionKey),h={encrypted:a.cryptoService.encryptMetadata(h,this.sessionKey)},f=!0),await this.appSyncClient.createEvent({sessionId:e,type:a.EventType.INTERACTIVE_PROMPT,source:s.source,content:m,metadata:h,promptId:s.prompt_id,isEncrypted:f?!0:void 0}),n.info("Interactive prompt sent to AppSync with dynamic options",{sessionId:e})}addFallbackOptions(e){e.options=[{number:"1",text:"Yes"},{number:"2",text:"Yes, and don't ask again"},{number:"3",text:"Reject and tell Claude what to do differently"}],e.submitMap={1:"1",2:"2",3:"3"},e.instructions="Reply with 1, 2, or 3. Append a message to provide alternative instructions."}buildPromptInstructions(e){return`Reply with ${e.options.map(t=>t.number).join(", ")}. Append a message to provide alternative instructions.`}parseInteractivePromptInput(e,s=3){return J(e,s)}async markEventExecuted(e){try{await this.appSyncClient.updateEventStatus({eventId:e.eventId,sessionId:e.sessionId,timestamp:e.timestamp,deliveryStatus:a.DeliveryStatus.EXECUTED}),n.info("Event marked as EXECUTED",{eventId:e.eventId})}catch(s){n.warn("Failed to mark event as EXECUTED",{eventId:e.eventId,error:s})}}async sendPromptError(e,s){await this.appSyncClient.createEvent({sessionId:e,type:a.EventType.NOTIFICATION,source:a.EventSource.DESKTOP,content:s,metadata:{error:!0}})}isSessionLimitExceeded(e){return this.getErrorMessage(e).includes("SESSION_LIMIT_EXCEEDED")}isUsageLimitExceeded(e){let s=this.getErrorMessage(e);return s.includes("MESSAGE_LIMIT_EXCEEDED")||s.includes("IMAGE_LIMIT_EXCEEDED")}getErrorMessage(e){if(e instanceof Error)return e.message;if(typeof e=="object"&&e!==null){let s=e;if(s.errors&&Array.isArray(s.errors))return s.errors.map(t=>t.message||"").join(" ");if(typeof s.message=="string")return s.message}return String(e)}displaySubscriptionLimitError(e,s){let t=this.getErrorMessage(e),i="",o=t.match(/for your (\w+) plan/i);o&&(i=` (${o[1]} tier)`);let r="",c=t.match(/of (\d+)/);switch(c&&(r=` [Limit: ${c[1]}]`),console.log(`
7
- `+"=".repeat(60)),console.log("\u26A0\uFE0F SUBSCRIPTION LIMIT REACHED"),console.log("=".repeat(60)),s){case"session":console.log(`You have reached the maximum number of active sessions${i}.`),console.log(`${r}`),console.log(`
8
- To continue, please:`),console.log(" \u2022 Close an existing Claude Code session, or"),console.log(" \u2022 Upgrade your subscription in the CodeVibe iOS app");break;case"message":console.log(`You have reached your monthly message limit${i}.`),console.log(`${r}`),console.log(`
9
- To continue, please:`),console.log(" \u2022 Wait until your usage resets next month, or"),console.log(" \u2022 Upgrade your subscription in the CodeVibe iOS app");break;case"image":console.log(`You have reached your monthly image attachment limit${i}.`),console.log(`${r}`),console.log(`
1
+ "use strict";var ue=Object.create;var D=Object.defineProperty;var me=Object.getOwnPropertyDescriptor;var ge=Object.getOwnPropertyNames;var he=Object.getPrototypeOf,fe=Object.prototype.hasOwnProperty;var ye=(m,e)=>{for(var t in e)D(m,t,{get:e[t],enumerable:!0})},X=(m,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of ge(e))!fe.call(m,n)&&n!==t&&D(m,n,{get:()=>e[n],enumerable:!(i=me(e,n))||i.enumerable});return m};var P=(m,e,t)=>(t=m!=null?ue(he(m)):{},X(e||!m||!m.__esModule?D(t,"default",{value:m,enumerable:!0}):t,m)),ve=m=>X(D({},"__esModule",{value:!0}),m);var be={};ye(be,{McpServer:()=>W,parseInteractivePromptInput:()=>ce});module.exports=ve(be);var k=P(require("fs")),R=P(require("path")),L=P(require("os")),oe=require("child_process"),ae=require("util"),pe=require("child_process");var Y=P(require("os")),z=P(require("path")),J=require("@quantiya/codevibe-core"),s=(0,J.createLogger)({name:"codevibe-claude",logFile:z.default.join(Y.default.tmpdir(),"codevibe-claude-mcp.log"),level:"info"});var p=require("@quantiya/codevibe-core");var q=P(require("express")),F=P(require("fs")),V=P(require("path")),G=P(require("os")),Z=require("@quantiya/codevibe-core");var f=require("@quantiya/codevibe-core");var $=class{constructor(){this.assignedPort=0;this.app=(0,q.default)(),this.setupMiddleware(),this.setupRoutes()}setSessionId(e){this.sessionId=e}getPort(){return this.assignedPort}setupMiddleware(){this.app.use(q.default.json({limit:"1mb"})),this.app.use((e,t,i)=>{s.debug(`${e.method} ${e.path}`,{body:e.body,query:e.query}),i()}),this.app.use((e,t,i,n)=>{s.error("Express error:",e);let r={success:!1,error:e.message||"Internal server error"};i.status(500).json(r)})}setupRoutes(){this.app.get("/health",this.handleHealth.bind(this)),this.app.post("/event",this.handleEvent.bind(this)),process.env.NODE_ENV!=="production"&&this.app.post("/test/execute",this.handleTestExecute.bind(this))}handleHealth(e,t){let i={success:!0,data:{status:"healthy",uptime:process.uptime(),version:"0.1.0",timestamp:new Date().toISOString()}};t.json(i)}async handleEvent(e,t){try{let i=e.body;if(!i.session_id){let o={success:!1,error:"Missing required field: session_id"};t.status(400).json(o);return}if(!i.hook_event_name){let o={success:!1,error:"Missing required field: hook_event_name"};t.status(400).json(o);return}let n=this.transformHookToEvent(i);s.info("Received event from hook",{sessionId:i.session_id,hookEvent:i.hook_event_name,type:n.type}),this.eventHandler?await this.eventHandler(n):s.warn("No event handler registered");let r={success:!0,message:"Event processed successfully"};t.json(r)}catch(i){s.error("Error handling event:",i);let n={success:!1,error:i instanceof Error?i.message:"Unknown error"};t.status(500).json(n)}}async handleTestExecute(e,t){try{let{sessionId:i,prompt:n}=e.body;if(!i||!n){let o={success:!1,error:"Missing required fields: sessionId, prompt"};t.status(400).json(o);return}s.info("Test execute request",{sessionId:i,prompt:n});let r={success:!0,message:"Test execution endpoint - not implemented yet",data:{sessionId:i,prompt:n}};t.json(r)}catch(i){s.error("Error in test execute:",i);let n={success:!1,error:i instanceof Error?i.message:"Unknown error"};t.status(500).json(n)}}transformHookToEvent(e){let t,i,n={cwd:e.cwd,hook_event_name:e.hook_event_name,...e.metadata||{}};if(e.type&&e.content!==void 0)t=e.type,i=e.content;else switch(e.hook_event_name){case"SessionStart":t=f.EventType.NOTIFICATION,i="Session started",n.source=e.source;break;case"SessionEnd":t=f.EventType.NOTIFICATION,i=`Session ended: ${e.reason||"unknown"}`,n.reason=e.reason;break;case"UserPromptSubmit":t=f.EventType.USER_PROMPT,i=e.prompt||"";break;case"PostToolUse":t=f.EventType.TOOL_USE,i=JSON.stringify({tool_name:e.tool_name,tool_input:e.tool_input,tool_response:e.tool_response}),n.tool_name=e.tool_name;break;case"Notification":t=f.EventType.NOTIFICATION,i=e.message||"",n.notification_type=e.notification_type;break;default:t=f.EventType.NOTIFICATION,i=`Hook event: ${e.hook_event_name}`}return{session_id:e.session_id,hook_event_name:e.hook_event_name,type:t,source:f.EventSource.DESKTOP,content:i,metadata:n}}onEvent(e){this.eventHandler=e}async start(e){let t=e||this.sessionId;return t&&(this.sessionId=t),new Promise((i,n)=>{try{let r=(0,Z.getConfig)(),o=r.server.dynamicPort?0:r.server.port;this.server=this.app.listen(o,r.server.host,()=>{let a=this.server.address();this.assignedPort=a.port,s.info(`HTTP API listening on http://${r.server.host}:${this.assignedPort}`),this.sessionId&&this.writePortFile(this.sessionId,this.assignedPort),i(this.assignedPort)}),this.server.on("error",a=>{s.error("HTTP server error:",a),n(a)})}catch(r){n(r)}})}writePortFile(e,t){let i=V.join(G.tmpdir(),`codevibe-claude-${e}.port`);try{F.writeFileSync(i,t.toString()),s.info(`Port file written: ${i} -> ${t}`)}catch(n){s.error(`Failed to write port file: ${i}`,n)}}removePortFile(){if(this.sessionId){let e=V.join(G.tmpdir(),`codevibe-claude-${this.sessionId}.port`);try{F.existsSync(e)&&(F.unlinkSync(e),s.info(`Port file removed: ${e}`))}catch(t){s.warn(`Failed to remove port file: ${e}`,t)}}}async stop(e){return new Promise((t,i)=>{this.sessionId&&e?.protectedSessionIds?.has(this.sessionId)?s.info("Skipping port file removal \u2014 another daemon still serves this session",{sessionId:this.sessionId}):this.removePortFile(),this.server?this.server.close(n=>{n?(s.error("Error stopping HTTP server:",n),i(n)):(s.info("HTTP API stopped"),t())}):t()})}};var ee=require("child_process"),te=require("@quantiya/codevibe-core");var U=class{async executePrompt(e,t){let i=(0,te.getConfig)(),n=i.claude.defaultTimeout;return s.info("Executing prompt from mobile",{sessionId:e,promptLength:t.length,timeout:n}),new Promise(r=>{let o=["--resume",e,"--print","--output-format","stream-json",t];s.debug("Spawning Claude command",{command:i.claude.command,args:o});let a=(0,ee.spawn)(i.claude.command,o,{stdio:["pipe","pipe","pipe"],shell:!0}),g="",c="",d=!1,u=setTimeout(()=>{d=!0,s.warn("Command execution timed out",{sessionId:e,timeout:n}),a.kill("SIGTERM")},n);a.stdout?.on("data",l=>{let y=l.toString();g+=y,s.debug("Command stdout",{output:y.slice(0,200)})}),a.stderr?.on("data",l=>{let y=l.toString();c+=y,s.debug("Command stderr",{output:y.slice(0,200)})}),a.on("close",l=>{clearTimeout(u);let y={success:l===0&&!d,output:g,error:c,exitCode:l||void 0,timedOut:d};y.success?s.info("Command executed successfully",{sessionId:e,exitCode:l,outputLength:g.length}):s.error("Command execution failed",{sessionId:e,exitCode:l,timedOut:d,error:c.slice(0,500)}),r(y)}),a.on("error",l=>{clearTimeout(u),s.error("Failed to spawn command",{error:l.message}),r({success:!1,error:l.message,timedOut:!1})})})}detectInteractivePrompt(e){return[/\[Y\/n\]/i,/\[y\/N\]/i,/\(y\/n\)/i,/Continue\?/i,/Proceed\?/i].some(i=>i.test(e))}extractPromptText(e){let t=e.split(`
2
+ `);for(let i=t.length-1;i>=0;i--){let n=t[i].trim();if(this.detectInteractivePrompt(n))return n}return null}};var ie=require("child_process"),se=require("util");var ne=(0,se.promisify)(ie.exec),K=class{async answerInteractivePrompt(e,t){s.info("Attempting to answer interactive prompt",{sessionId:e,response:t});try{let i=process.env.CODEVIBE_TMUX_SESSION;return s.info("Checking tmux session environment",{tmuxSession:i||"(not set)",allEnvKeys:Object.keys(process.env).filter(n=>n.includes("CODEVIBE")||n.includes("TMUX"))}),i?(s.info("Using tmux send-keys",{tmuxSession:i}),await this.sendViaTmux(i,t),s.info("Successfully sent response to interactive prompt",{sessionId:e,response:t}),!0):(s.error("No tmux session found - codevibe-claude wrapper is required",{sessionId:e,hint:"Start Claude Code using the codevibe-claude wrapper script"}),!1)}catch(i){return s.error("Failed to answer interactive prompt",{sessionId:e,error:i instanceof Error?i.message:String(i)}),!1}}async sendViaTmux(e,t){let i=t.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\$/g,"\\$").replace(/`/g,"\\`");s.info("Sending via tmux",{sessionName:e,inputLength:t.length});try{let n=`tmux send-keys -t "${e}" -l "${i}"`,r=await ne(n);s.info("tmux send-keys (text) completed",{stdout:r.stdout||"(empty)",stderr:r.stderr||"(empty)"}),await this.delay(500);let o=`tmux send-keys -t "${e}" Enter`,a=await ne(o);s.info("tmux send-keys (Enter) completed",{stdout:a.stdout||"(empty)",stderr:a.stderr||"(empty)"})}catch(n){throw s.error("tmux send-keys failed",{sessionName:e,error:n}),n}}delay(e){return new Promise(t=>setTimeout(t,e))}isPromptResponse(e){let t=e.trim().toLowerCase();return!!(t==="y"||t==="n"||t==="yes"||t==="no"||/^[0-9]+$/.test(t)||/^[a-z]$/.test(t)||["exit","quit","q","continue","skip","abort","retry","cancel"].includes(t))}};var Ee=(0,ae.promisify)(pe.exec),we="/exit",re="CODEVIBE_TMUX_SESSION";async function Ie(m,e){let t=async(i,n)=>{try{await Ee(i)}catch(r){s.warn("tmux send-keys failed during self-terminate",{sessionName:m,label:n,error:String(r)})}};await t(`tmux send-keys -t "${m}" C-c`,"ctrl-c"),await new Promise(i=>setTimeout(i,200)),await t(`tmux send-keys -t "${m}" -l "${e}"`,"quit-text"),await new Promise(i=>setTimeout(i,500)),await t(`tmux send-keys -t "${m}" Enter`,"enter")}var W=class m{constructor(e){this.activeSessions=new Map;this.assignedPort=0;this.sessionKey=null;this.claudeToBackendSessionId=new Map;this.pendingMobilePrompts=new Map;this.nextPromptGen=1;this.httpApi=new $,this.commandExecutor=new U,this.promptResponder=new K,this.initialSessionId=e}static{this.MOBILE_PROMPT_EXPIRY_MS=3e3}getPort(){return this.assignedPort}generateBackendSessionId(e){return`claude-${e}`}trackMobilePrompt(e,t){this.pendingMobilePrompts.has(e)||this.pendingMobilePrompts.set(e,[]),this.pendingMobilePrompts.get(e).push({prompt:t.trim(),timestamp:Date.now()}),s.debug("Tracking mobile prompt for deduplication",{sessionId:e,promptLength:t.length})}isRecentMobilePrompt(e,t){let i=this.pendingMobilePrompts.get(e);if(!i)return!1;let n=Date.now(),r=t.trim(),o=[],a=!1;for(let g of i)if(!(n-g.timestamp>m.MOBILE_PROMPT_EXPIRY_MS)){if(!a&&g.prompt===r){a=!0,s.debug("Found matching mobile prompt, filtering duplicate",{sessionId:e});continue}o.push(g)}return o.length>0?this.pendingMobilePrompts.set(e,o):this.pendingMobilePrompts.delete(e),a}writePortFile(e){let t=R.join(L.tmpdir(),`codevibe-claude-${e}.port`);try{k.writeFileSync(t,this.assignedPort.toString()),s.info(`Port file written: ${t} -> ${this.assignedPort}`)}catch(i){s.error(`Failed to write port file: ${t}`,i)}}removePortFile(e){let t=R.join(L.tmpdir(),`codevibe-claude-${e}.port`);try{k.existsSync(t)&&(k.unlinkSync(t),s.info(`Port file removed: ${t}`))}catch(i){s.warn(`Failed to remove port file: ${t}`,i)}}hasOtherLiveDaemonForSession(e){try{let t=(0,oe.execSync)("ps -eww -o pid= -o args=",{encoding:"utf8",timeout:2e3}),i=process.pid;for(let n of t.split(`
3
+ `)){let r=n.trim();if(!r)continue;let o=r.indexOf(" ");if(o<0)continue;let a=parseInt(r.substring(0,o),10);if(isNaN(a)||a===i)continue;let g=r.substring(o+1);if(/node.*codevibe-claude.*server\.js/.test(g)&&g.includes(e))return!0}return!1}catch(t){return s.warn('hasOtherLiveDaemonForSession: ps query failed; falling back to "no other daemon"',{error:String(t)}),!1}}async start(){try{if(s.info("Starting CodeVibe MCP Server...",{environment:(0,p.getEnvironment)()}),this.appSyncClient=new p.AppSyncClient,await this.appSyncClient.authenticateWithStoredTokens()){s.info("Authenticated with stored OAuth tokens",{userId:this.appSyncClient.getCurrentUserId(),email:this.appSyncClient.getCurrentUserEmail()}),await(0,p.registerDeviceEncryptionKey)(this.appSyncClient,s),(0,p.startDeviceKeyWatcher)(this.appSyncClient,s);try{let t=await this.appSyncClient.sweepOrphanSessions({agentType:"CLAUDE"});t>0&&s.info("Orphan sweep: marked stale Claude sessions INACTIVE",{swept:t})}catch(t){s.warn("Orphan sweep failed, continuing startup",{error:t instanceof Error?t.message:String(t)})}}else s.error('Authentication failed. Run "codevibe-claude login" first.'),console.error('Not authenticated. Run "codevibe-claude login" to sign in.'),process.exit(1);this.httpApi.onEvent(this.handleEventFromHook.bind(this)),this.assignedPort=await this.httpApi.start(this.initialSessionId),s.info("MCP Server started successfully",{port:this.assignedPort,host:(0,p.getConfig)().server.host,dynamicPort:(0,p.getConfig)().server.dynamicPort,sessionId:this.initialSessionId,authenticated:this.appSyncClient.isAuthenticated(),userId:this.appSyncClient.getCurrentUserId()})}catch(e){throw s.error("Failed to start MCP Server:",e),e}}async stop(){s.info("Stopping MCP Server...");let e=Array.from(this.activeSessions.keys()),t=new Set;s.info(`Marking ${e.length} active session(s) as INACTIVE...`);for(let i of e){let n=this.activeSessions.get(i);n?.mobileEndWatcher&&(n.mobileEndWatcher.stop(),n.mobileEndWatcher=void 0)}for(let i of e)try{let n=this.activeSessions.get(i);if(n&&this.hasOtherLiveDaemonForSession(n.claudeSessionId)){s.info("Another daemon serves this session \u2014 skipping mark INACTIVE AND port file removal during shutdown",{sessionId:i,claudeSessionId:n.claudeSessionId,myPid:process.pid}),t.add(n.claudeSessionId);continue}await this.appSyncClient.updateSession({sessionId:i,status:p.SessionStatus.INACTIVE}),s.info("Session marked as INACTIVE during shutdown",{sessionId:i}),n&&this.removePortFile(n.claudeSessionId)}catch(n){s.warn("Failed to mark session as INACTIVE during shutdown",{sessionId:i,error:n})}this.appSyncClient.cleanupSubscriptions(),this.activeSessions.clear(),await this.httpApi.stop({protectedSessionIds:t}),s.info("MCP Server stopped")}async handleEventFromHook(e){let{session_id:t,hook_event_name:i,type:n,content:r}=e;s.info("Processing hook event",{sessionId:t,hookEvent:i,type:n});try{i==="SessionStart"?await this.handleSessionStart(e):i==="SessionEnd"&&await this.handleSessionEnd(e);let o=this.claudeToBackendSessionId.get(t)||this.generateBackendSessionId(t);if(n===p.EventType.USER_PROMPT&&e.source===p.EventSource.DESKTOP&&i==="UserPromptSubmit"&&r&&this.isRecentMobilePrompt(o,r)){s.info("Skipping duplicate USER_PROMPT from mobile-originated prompt",{sessionId:o,contentLength:r.length});return}if(n===p.EventType.INTERACTIVE_PROMPT){let u=this.activeSessions.get(o);if(u){this.clearWalkerAndLegacyState(u),u.waitingForPromptResponse=!0,u.pendingPromptId=e.prompt_id;let l=this.nextPromptGen++;u.promptGenerationToken={promptId:e.prompt_id||"",gen:l},s.info("Interactive prompt detected - will parse options from tmux",{sessionId:o,promptId:e.prompt_id,tokenGen:l})}this.sendInteractivePromptAsync(o,e,r).catch(l=>{s.error("Failed to send interactive prompt with dynamic options",{error:l})});return}let a=r,g=e.metadata,c=!1;s.info("Hook event encryption state",{type:n,sessionId:o,hasSessionKey:!!this.sessionKey,sessionKeyLength:this.sessionKey?.length||0}),this.sessionKey?(a=p.cryptoService.encryptContent(r,this.sessionKey),g&&(g={encrypted:p.cryptoService.encryptMetadata(g,this.sessionKey)}),c=!0,s.info("Event encrypted for hook",{type:n,sessionId:o,isEncrypted:!0})):s.warn("No session key - event will NOT be encrypted",{type:n,sessionId:o});let d=await this.appSyncClient.createEvent({sessionId:o,type:n,source:e.source,content:a,metadata:g,promptId:e.prompt_id,isEncrypted:c?!0:void 0});if(n===p.EventType.USER_PROMPT&&e.source===p.EventSource.DESKTOP){let u=this.activeSessions.get(o);u?.waitingForPromptResponse&&(this.clearWalkerAndLegacyState(u),s.info("Clearing prompt wait state - new desktop prompt received",{sessionId:o}))}s.debug("Event sent to AppSync successfully")}catch(o){throw s.error("Failed to process hook event:",o),o}}async handleSessionStart(e){let t=e.session_id,i=this.generateBackendSessionId(t),n=e.metadata?.cwd||process.cwd();this.claudeToBackendSessionId.set(t,i),s.info("Session started",{claudeSessionId:t,sessionId:i,cwd:n});let r=Array.from(this.activeSessions.keys()).filter(c=>c!==i);if(r.length>0){s.info(`Marking ${r.length} previous session(s) as INACTIVE`);for(let c of r){let d=this.activeSessions.get(c);d?.mobileEndWatcher&&(d.mobileEndWatcher.stop(),d.mobileEndWatcher=void 0);try{await this.appSyncClient.updateSession({sessionId:c,status:p.SessionStatus.INACTIVE}),s.info("Previous session marked INACTIVE",{prevId:c,newSessionId:i})}catch(u){s.warn("Failed to mark previous session as INACTIVE",{prevId:c,error:u})}d&&this.removePortFile(d.claudeSessionId),this.activeSessions.delete(c)}}this.writePortFile(t);let o=this.appSyncClient.getCurrentUserId(),a={sessionId:i,claudeSessionId:t,userId:o,projectPath:n,cwd:n,createdAt:new Date,subscriptionActive:!1,waitingForPromptResponse:!1,metadata:e.metadata||{}};this.activeSessions.set(i,a);try{let c=await(0,p.resumeOrCreateSession)({sessionId:i,userId:a.userId,agentType:p.AgentType.CLAUDE,projectPath:n,metadata:e.metadata||{}},this.appSyncClient,s);if(this.sessionKey=c.sessionKey,c.resumed&&!c.sessionKey){let d=await p.keychainManager.getDeviceId();s.error("Device key not found in session encryptedKeys",{sessionId:i,pluginDeviceId:d}),console.error(`
4
+ \u26A0\uFE0F E2E ENCRYPTION WARNING: Cannot decrypt this session!`),console.error(` Your device ID (${d.substring(0,8)}...) is not in session's encryption keys.`),console.error(" This happens if your device key was regenerated after the session was created."),console.error(` SOLUTION: Start a new Claude Code session instead of resuming this one.
5
+ `)}}catch(c){if(this.isSessionLimitExceeded(c)){this.displaySubscriptionLimitError(c,"session"),this.activeSessions.delete(i),this.removePortFile(t);return}s.error("Failed to create/resume session:",c)}this.subscribeToMobileEvents(i),this.appSyncClient.startHeartbeat(i);let g=this.activeSessions.get(i);g&&(g.mobileEndWatcher=this.appSyncClient.watchForMobileEnd(i,async()=>{s.info("Mobile ended session \u2014 sending desktop quit",{sessionId:i});let c=process.env[re];if(!c){s.warn("No tmux session set; skipping desktop self-terminate",{sessionId:i,expectedEnv:re});return}await Ie(c,we)}))}async handleSessionEnd(e){let t=e.session_id,i=this.claudeToBackendSessionId.get(t)||this.generateBackendSessionId(t);s.info("Session ended",{claudeSessionId:t,sessionId:i,reason:e.metadata?.reason});let n=this.activeSessions.get(i);if(n?.mobileEndWatcher&&(n.mobileEndWatcher.stop(),n.mobileEndWatcher=void 0),this.removePortFile(t),n?.waitingForPromptResponse&&(s.info("Clearing prompt wait state - session ending",{sessionId:i}),this.clearWalkerAndLegacyState(n)),this.appSyncClient.stopHeartbeat(i),n)try{await this.appSyncClient.updateSession({sessionId:i,status:p.SessionStatus.INACTIVE}),s.info("Session marked as INACTIVE in AppSync",{sessionId:i})}catch(r){s.warn("Failed to update session in AppSync:",r)}else s.warn("Cannot update session - session state not found",{sessionId:i});this.activeSessions.delete(i),this.claudeToBackendSessionId.delete(t),s.debug("Session cleanup completed",{sessionId:i})}subscribeToMobileEvents(e){s.info("Subscribing to mobile events",{sessionId:e});let t=this.activeSessions.get(e);if(!t){s.error("Session not found",{sessionId:e});return}this.appSyncClient.subscribeToEvents(e,async i=>{await this.dispatchMobileEvent(e,i)},i=>{s.error("Subscription error",{sessionId:e,error:i})}),t.subscriptionActive=!0,s.info("Subscription active",{sessionId:e})}async dispatchMobileEvent(e,t){s.info("Received mobile event",{eventId:t.eventId,type:t.type,sessionId:t.sessionId,isEncrypted:t.isEncrypted});let i,n,r=!1,o,a,g;if(t.type===p.EventType.USER_PROMPT)if(n=this.activeSessions.get(e),!n)i="no-session";else if(n.processedEventIds?.has(t.eventId))i="skip-dedup";else if(n.inFlightEventIds?.has(t.eventId))i="drop-event-redeliver";else if(n.waitingForPromptResponse){let u=n.promptGenerationToken;if(!u)i="regular";else{let l=u.promptId.length>0?u.promptId:`__prompt_gen_${u.gen}`;n.inFlightPromptIds?.has(l)?i="drop-in-flight":(n.inFlightPromptIds||(n.inFlightPromptIds=new Set),n.inFlightEventIds||(n.inFlightEventIds=new Set),n.inFlightPromptIds.add(l),n.inFlightEventIds.add(t.eventId),r=!0,a=l,g=t.eventId,o={promptId:u.promptId,gen:u.gen},i="walker")}}else i="regular";else i="not-user-prompt";let c=t.content||"";if(t.isEncrypted&&this.sessionKey)try{c=p.cryptoService.decryptContent(t.content,this.sessionKey),s.debug("Event decrypted successfully",{eventId:t.eventId})}catch(u){s.error("Failed to decrypt event:",{eventId:t.eventId,error:u}),c=t.content}let d={...t,content:c};try{await this.appSyncClient.updateEventStatus({eventId:t.eventId,sessionId:t.sessionId,timestamp:t.timestamp,deliveryStatus:p.DeliveryStatus.DELIVERED}),s.info("Event marked as DELIVERED",{eventId:t.eventId})}catch(u){s.warn("Failed to mark event as DELIVERED",{eventId:t.eventId,error:u})}if(i==="skip-dedup"){s.info("[walker] Subscription-level dedup hit (already processed) \u2014 skipping",{sessionId:e,eventId:t.eventId});return}if(i==="drop-in-flight"){s.warn("[walker] Subscription-level in-flight guard \u2014 dropping duplicate USER_PROMPT (different eventId, same prompt)",{sessionId:e,eventId:t.eventId}),n&&(n.processedEventIds||(n.processedEventIds=new Set),n.processedEventIds.add(t.eventId));try{await this.markEventExecuted(t)}catch(u){s.warn("[walker] markEventExecuted threw on subscription-level duplicate drop \u2014 relying on processedEventIds Set",{sessionId:e,eventId:t.eventId,error:String(u)})}return}if(i==="drop-event-redeliver"){s.info("[walker] Subscription-level event-level redelivery \u2014 silent skip (original still in flight)",{sessionId:e,eventId:t.eventId});return}if(i==="walker"){await this.handleMobilePromptResponse(e,t,c,n,r,o,a,g);return}if(i==="regular"){await this.executeMobilePrompt(e,d);return}if(i==="no-session"){s.warn("Received USER_PROMPT for unknown session \u2014 ignoring",{sessionId:e,eventId:t.eventId});return}}async handleMobilePromptResponse(e,t,i,n,r=!1,o,a,g){let c=o??n.promptGenerationToken,d=a,u=g;if(!r&&c){let l=c.promptId.length>0?c.promptId:`__prompt_gen_${c.gen}`;if(n.inFlightPromptIds?.has(l)){s.warn("[walker] Duplicate mobile USER_PROMPT for same prompt \u2014 dropping",{sessionId:e,eventId:t.eventId,lockKey:l}),await this.markEventExecutedIdempotent(n,t);return}n.inFlightPromptIds||(n.inFlightPromptIds=new Set),n.inFlightEventIds||(n.inFlightEventIds=new Set),n.inFlightPromptIds.add(l),n.inFlightEventIds.add(t.eventId),d=l,u=t.eventId}try{if(!r&&n.processedEventIds?.has(t.eventId)){s.info("[walker] Redelivered event already processed \u2014 skipping",{sessionId:e,eventId:t.eventId});return}let l=i.trim(),y=n.pendingPromptId,_=n.pendingQuestionsSubmitMaps,O=n.pendingSubmitMap,T=n.pendingQuestionAnswerCount??0,N=!!_,C=N?_[T]:O,w=C?Object.keys(C).length:3,E=this.parseInteractivePromptInput(l,w);s.info("Parsed interactive prompt input",{sessionId:e,content:l,parsed:E,isMultiQWalk:N,capturedAnswerCount:T,hasSubmitMap:!!C});let I=()=>{let S=n.promptGenerationToken,h=S?.gen,v=c?.gen;return h!==v?(s.warn("[walker] Token mismatch \u2014 external cleanup or new prompt during in-flight handler \u2014 aborting",{sessionId:e,eventId:t.eventId,entryToken:c,currentToken:S}),!0):!1};if(I()){await this.markEventExecutedIdempotent(n,t);return}if(E.action==="select_option"){let S=C?.[E.option]||E.option;s.info("User selected option",{option:E.option,terminalInput:S});let h=await this.promptResponder.answerInteractivePrompt(e,S);if(I()){await this.markEventExecutedIdempotent(n,t);return}if(h){if(await this.markEventExecutedIdempotent(n,t),I())return;if(N){let v=T,b=v+1,x=_.length,M=b>=x;if(await this.emitWalkerNotification(e,`Selected option ${E.option}`,{promptId:y,questionIndex:v,isTerminal:M}),I())return;M?this.clearWalkerAndLegacyState(n):n.pendingQuestionAnswerCount=b}else{if(await this.emitWalkerNotification(e,`Selected option ${E.option}`,{promptId:y,questionIndex:0,isTerminal:!0}),I())return;this.clearWalkerAndLegacyState(n)}}else try{await this.sendWalkerError(e,"Failed to select option")}catch(v){s.warn("[walker] sendWalkerError threw \u2014 relying on idempotent mark in finally",{sessionId:e,eventId:t.eventId,error:String(v)})}finally{await this.markEventExecutedIdempotent(n,t)}}else if(E.action==="option_with_followup"){let S=C?.[E.option]||E.option,h=T;s.info("User selected option with follow-up",{option:E.option,terminalInput:S,followUpText:E.followUpText});let v=await this.promptResponder.answerInteractivePrompt(e,S);if(I()){await this.markEventExecutedIdempotent(n,t);return}if(v){if(await this.markEventExecutedIdempotent(n,t),I()||(await this.emitWalkerNotification(e,`Selected option ${E.option}`,{promptId:y,questionIndex:h,isTerminal:!0}),I()))return;if(this.clearWalkerAndLegacyState(n),E.followUpText){await new Promise(x=>setTimeout(x,1e3));let b={...t,content:E.followUpText};await this.executeMobilePrompt(e,b)}}else try{await this.sendWalkerError(e,"Failed to select option. Your reply (including the follow-up text) was not sent. Please retry.")}catch(b){s.warn("[walker] sendWalkerError threw \u2014 relying on idempotent mark in finally",{sessionId:e,eventId:t.eventId,error:String(b)})}finally{await this.markEventExecutedIdempotent(n,t)}}else{let S=T;s.info("Sending as free-form response to interactive prompt",{response:l});let h=await this.promptResponder.answerInteractivePrompt(e,l);if(I()){await this.markEventExecutedIdempotent(n,t);return}if(h){if(await this.markEventExecutedIdempotent(n,t),I()||(await this.emitWalkerNotification(e,"Response sent to interactive prompt",{promptId:y,questionIndex:S,isTerminal:!0}),I()))return;this.clearWalkerAndLegacyState(n)}else try{await this.sendWalkerError(e,"Failed to send response")}catch(v){s.warn("[walker] sendWalkerError threw \u2014 relying on idempotent mark in finally",{sessionId:e,eventId:t.eventId,error:String(v)})}finally{await this.markEventExecutedIdempotent(n,t)}}}finally{d&&n.inFlightPromptIds&&n.inFlightPromptIds.delete(d),u&&n.inFlightEventIds&&n.inFlightEventIds.delete(u)}}async sendInteractivePromptAsync(e,t,i){let n=this.activeSessions.get(e),r=n?.promptGenerationToken?{...n.promptGenerationToken}:void 0;await new Promise(w=>setTimeout(w,500));let o=process.env.CODEVIBE_TMUX_SESSION,a={...t.metadata||{}},g=t.metadata?.tool_name,c=t.metadata?.tool_input,d=g==="AskUserQuestion"&&Array.isArray(c?.questions)?c.questions:[],u=d.length>1&&d.every(w=>!w.multiSelect),l=d.length===1||u;if(d.length>0&&Array.isArray(d[0]?.options)&&d[0].options.length>0){let w=d.map(h=>{let v=(h.options||[]).map((A,de)=>{let B=typeof A=="string",le=B?A:A.label||"",H=B?"":A.description||"",Q={number:String(de+1),text:le};return H&&(Q.description=H),Q}),b=String(v.length+1),x=String(v.length+2);v.push({number:b,text:"Type something"},{number:x,text:"Chat about this"});let M=Object.fromEntries(v.map(A=>[A.number,A.number])),j=h.multiSelect?`Reply with comma-separated numbers (e.g., 1,3) for "${h.header||h.question}"`:`Reply with the number of your choice. For option ${b} (Type something), reply "${b}, your answer".`;return{question:h.question,header:h.header,multiSelect:!!h.multiSelect,options:v,submitMap:M,instructions:j}}),E=w[0];a.options=JSON.parse(JSON.stringify(E.options)),a.submitMap=JSON.parse(JSON.stringify(E.submitMap)),a.instructions=E.instructions,i=d[0].question;let I=typeof t.prompt_id=="string"&&t.prompt_id.length>0,S=l&&I;if(S){a.questions=w;let h=this.activeSessions.get(e);if(h){let v=h.promptGenerationToken;r&&v?.gen===r.gen?(h.pendingQuestionsSubmitMaps=w.map(b=>b.submitMap),h.pendingQuestionAnswerCount=0):s.warn("AskUserQuestion multi-Q: stale async \u2014 token gen mismatch, skipping walker-field write",{tokenAtEmit:r,currentToken:v,sessionId:e})}}l&&!I&&s.warn("AskUserQuestion multi-Q: empty prompt_id, degrading to single-Q legacy emit",{questionCount:d.length}),s.info("AskUserQuestion: using tool_input directly (skipped tmux parse)",{questionCount:d.length,multiQEmit:S,optionCountFirst:E.options.length,multiSelectFirst:!!d[0].multiSelect,questionPreview:d[0].question.slice(0,80)})}else if(o)try{let{exec:w}=await import("child_process"),E=v=>new Promise((b,x)=>{w(v,{timeout:5e3},(M,j)=>{M?x(M):b({stdout:j||""})})}),{stdout:I}=await E(`tmux capture-pane -p -e -S -30 -t '${o}'`),S=I.split(`
6
+ `);s.info("tmux capture result",{tmuxSession:o,totalLines:S.length,lastLines:S.slice(-15).map(v=>v.replace(/\x1B[^m]*m/g,"").trim()).filter(Boolean)});let h=(0,p.parseInteractivePrompt)(I);h&&h.options.length>0?(a.options=h.options,a.submitMap=h.submitMap,a.instructions=this.buildPromptInstructions(h),s.info("Parsed dynamic options from tmux",{optionCount:h.options.length,kind:h.kind,options:h.options})):(s.info("No dynamic options parsed from tmux, using fallback",{parsedResult:h}),this.addFallbackOptions(a))}catch(w){s.warn("Failed to capture tmux pane for options",{error:w}),this.addFallbackOptions(a)}else s.warn("No tmux session \u2014 using fallback options"),this.addFallbackOptions(a);let y=this.activeSessions.get(e);if(y&&a.submitMap){let w=y.promptGenerationToken;r&&w?.gen===r.gen?y.pendingSubmitMap=a.submitMap:s.warn("Interactive prompt async: stale async \u2014 token gen mismatch, skipping pendingSubmitMap write",{tokenAtEmit:r,currentToken:w,sessionId:e})}let _=i,O=a,T=!1;this.sessionKey&&(_=p.cryptoService.encryptContent(i,this.sessionKey),O={encrypted:p.cryptoService.encryptMetadata(O,this.sessionKey)},T=!0);let C=this.activeSessions.get(e)?.promptGenerationToken;if(r&&C?.gen!==r.gen){s.warn("Interactive prompt emit: stale token \u2014 newer INTERACTIVE_PROMPT replaced ours; skipping AppSync emit",{sessionId:e,tokenAtEmit:r,currentToken:C});return}await this.appSyncClient.createEvent({sessionId:e,type:p.EventType.INTERACTIVE_PROMPT,source:t.source,content:_,metadata:O,promptId:t.prompt_id,isEncrypted:T?!0:void 0}),s.info("Interactive prompt sent to AppSync with dynamic options",{sessionId:e})}addFallbackOptions(e){e.options=[{number:"1",text:"Yes"},{number:"2",text:"Yes, and don't ask again"},{number:"3",text:"Reject and tell Claude what to do differently"}],e.submitMap={1:"1",2:"2",3:"3"},e.instructions="Reply with 1, 2, or 3. Append a message to provide alternative instructions."}buildPromptInstructions(e){return`Reply with ${e.options.map(i=>i.number).join(", ")}. Append a message to provide alternative instructions.`}parseInteractivePromptInput(e,t=3){return ce(e,t)}async markEventExecuted(e){try{await this.appSyncClient.updateEventStatus({eventId:e.eventId,sessionId:e.sessionId,timestamp:e.timestamp,deliveryStatus:p.DeliveryStatus.EXECUTED}),s.info("Event marked as EXECUTED",{eventId:e.eventId})}catch(t){s.warn("Failed to mark event as EXECUTED",{eventId:e.eventId,error:t})}}async sendPromptError(e,t){await this.appSyncClient.createEvent({sessionId:e,type:p.EventType.NOTIFICATION,source:p.EventSource.DESKTOP,content:t,metadata:{error:!0}})}async emitWalkerNotification(e,t,i){let n={promptAnswered:!0,...i},r=t,o=n,a=!1;this.sessionKey&&(r=p.cryptoService.encryptContent(t,this.sessionKey),o={encrypted:p.cryptoService.encryptMetadata(n,this.sessionKey)},a=!0),await this.appSyncClient.createEvent({sessionId:e,type:p.EventType.NOTIFICATION,source:p.EventSource.DESKTOP,content:r,metadata:o,isEncrypted:a?!0:void 0})}async sendWalkerError(e,t){let i={error:!0},n=t,r=i,o=!1;this.sessionKey&&(n=p.cryptoService.encryptContent(t,this.sessionKey),r={encrypted:p.cryptoService.encryptMetadata(i,this.sessionKey)},o=!0),await this.appSyncClient.createEvent({sessionId:e,type:p.EventType.NOTIFICATION,source:p.EventSource.DESKTOP,content:n,metadata:r,isEncrypted:o?!0:void 0})}clearWalkerAndLegacyState(e){e.waitingForPromptResponse=!1,e.pendingPromptId=void 0,e.pendingSubmitMap=void 0,e.pendingQuestionsSubmitMaps=void 0,e.pendingQuestionAnswerCount=void 0,e.promptGenerationToken=void 0}async markEventExecutedIdempotent(e,t){e.processedEventIds||(e.processedEventIds=new Set),e.processedEventIds.add(t.eventId);try{await this.markEventExecuted(t)}catch(i){s.warn("[walker] markEventExecuted threw \u2014 relying on processedEventIds set for dedup",{sessionId:t.sessionId,eventId:t.eventId,error:String(i)})}}isSessionLimitExceeded(e){return this.getErrorMessage(e).includes("SESSION_LIMIT_EXCEEDED")}isUsageLimitExceeded(e){let t=this.getErrorMessage(e);return t.includes("MESSAGE_LIMIT_EXCEEDED")||t.includes("IMAGE_LIMIT_EXCEEDED")}getErrorMessage(e){if(e instanceof Error)return e.message;if(typeof e=="object"&&e!==null){let t=e;if(t.errors&&Array.isArray(t.errors))return t.errors.map(i=>i.message||"").join(" ");if(typeof t.message=="string")return t.message}return String(e)}displaySubscriptionLimitError(e,t){let i=this.getErrorMessage(e),n="",r=i.match(/for your (\w+) plan/i);r&&(n=` (${r[1]} tier)`);let o="",a=i.match(/of (\d+)/);switch(a&&(o=` [Limit: ${a[1]}]`),console.log(`
7
+ `+"=".repeat(60)),console.log("\u26A0\uFE0F SUBSCRIPTION LIMIT REACHED"),console.log("=".repeat(60)),t){case"session":console.log(`You have reached the maximum number of active sessions${n}.`),console.log(`${o}`),console.log(`
8
+ To continue, please:`),console.log(" \u2022 Close an existing Claude Code session, or"),console.log(" \u2022 Upgrade your subscription in the CodeVibe iOS app");break;case"message":console.log(`You have reached your monthly message limit${n}.`),console.log(`${o}`),console.log(`
9
+ To continue, please:`),console.log(" \u2022 Wait until your usage resets next month, or"),console.log(" \u2022 Upgrade your subscription in the CodeVibe iOS app");break;case"image":console.log(`You have reached your monthly image attachment limit${n}.`),console.log(`${o}`),console.log(`
10
10
  To continue, please:`),console.log(" \u2022 Wait until your usage resets next month, or"),console.log(" \u2022 Upgrade your subscription in the CodeVibe iOS app");break}console.log(`
11
11
  Note: You can still use Claude Code normally from your desktop.`),console.log("This limit only affects syncing with the mobile app."),console.log("=".repeat(60)+`
12
- `),n.error("Subscription limit exceeded",{limitType:s,errorMessage:t})}async downloadAttachment(e,s,t){try{let i=e.isEncrypted??t??!1;n.info("Downloading attachment - START",{id:e.id,type:e.type,filename:e.filename,s3Key:e.s3Key,attachmentIsEncrypted:e.isEncrypted,eventIsEncrypted:t,shouldDecrypt:i,hasSessionKey:!!this.sessionKey});let{downloadUrl:o}=await this.appSyncClient.getAttachmentDownloadUrl(e.s3Key),r=await fetch(o);if(!r.ok)throw new Error(`Failed to download attachment: ${r.status} ${r.statusText}`);let c=Buffer.from(await r.arrayBuffer());if(n.info("Attachment downloaded",{id:e.id,downloadedSize:c.length,first20Bytes:c.slice(0,20).toString("hex")}),n.info("Checking decryption conditions",{id:e.id,shouldDecrypt:i,hasSessionKey:!!this.sessionKey,willDecrypt:!!(i&&this.sessionKey)}),i&&this.sessionKey)try{n.info("Decrypting attachment",{id:e.id,encryptedSize:c.length}),c=a.cryptoService.decryptData(c,this.sessionKey),n.info("Attachment decrypted successfully",{id:e.id,decryptedSize:c.length,first20Bytes:c.slice(0,20).toString("hex")})}catch(u){throw n.error("Failed to decrypt attachment:",{id:e.id,error:u}),new Error("Failed to decrypt attachment")}else i&&!this.sessionKey?n.warn("Cannot decrypt attachment - no session key available",{id:e.id}):n.info("Skipping decryption - attachment not encrypted or no session key",{id:e.id,shouldDecrypt:i,hasSessionKey:!!this.sessionKey});let d=w.join(_.tmpdir(),"codevibe-claude",s);E.existsSync(d)||E.mkdirSync(d,{recursive:!0});let p="",m=e.filename;if(i&&e.filename&&this.sessionKey)try{m=a.cryptoService.decryptContent(e.filename,this.sessionKey)}catch{m=e.filename}if(m){let u=w.extname(m);u&&(p=u)}p||(p={"image/jpeg":".jpg","image/png":".png","image/gif":".gif","image/webp":".webp","image/heic":".heic","application/pdf":".pdf"}[e.type]||".bin");let h=`attachment-${e.id}${p}`,f=w.join(d,h);return E.writeFileSync(f,c),n.info("Attachment saved to temp file",{id:e.id,filePath:f,size:c.length,wasDecrypted:i&&!!this.sessionKey}),f}catch(i){return n.error("Failed to download attachment:",{id:e.id,error:i}),null}}async executeMobilePrompt(e,s){let t=s.content||"",i=s.attachments||[];n.info("Executing mobile prompt via tmux",{sessionId:e,promptLength:t.length,attachmentCount:i.length});let o=[];if(i.length>0){n.info("Downloading attachments for prompt",{count:i.length});for(let r of i){let c=await this.downloadAttachment(r,e,s.isEncrypted);c&&o.push(c)}if(o.length>0){let r=o.map(c=>`[Attached file: ${c}]`).join(`
13
- `);t?t=`${r}
12
+ `),s.error("Subscription limit exceeded",{limitType:t,errorMessage:i})}async downloadAttachment(e,t,i){try{let n=e.isEncrypted??i??!1;s.info("Downloading attachment - START",{id:e.id,type:e.type,filename:e.filename,s3Key:e.s3Key,attachmentIsEncrypted:e.isEncrypted,eventIsEncrypted:i,shouldDecrypt:n,hasSessionKey:!!this.sessionKey});let{downloadUrl:r}=await this.appSyncClient.getAttachmentDownloadUrl(e.s3Key),o=await fetch(r);if(!o.ok)throw new Error(`Failed to download attachment: ${o.status} ${o.statusText}`);let a=Buffer.from(await o.arrayBuffer());if(s.info("Attachment downloaded",{id:e.id,downloadedSize:a.length,first20Bytes:a.slice(0,20).toString("hex")}),s.info("Checking decryption conditions",{id:e.id,shouldDecrypt:n,hasSessionKey:!!this.sessionKey,willDecrypt:!!(n&&this.sessionKey)}),n&&this.sessionKey)try{s.info("Decrypting attachment",{id:e.id,encryptedSize:a.length}),a=p.cryptoService.decryptData(a,this.sessionKey),s.info("Attachment decrypted successfully",{id:e.id,decryptedSize:a.length,first20Bytes:a.slice(0,20).toString("hex")})}catch(y){throw s.error("Failed to decrypt attachment:",{id:e.id,error:y}),new Error("Failed to decrypt attachment")}else n&&!this.sessionKey?s.warn("Cannot decrypt attachment - no session key available",{id:e.id}):s.info("Skipping decryption - attachment not encrypted or no session key",{id:e.id,shouldDecrypt:n,hasSessionKey:!!this.sessionKey});let g=R.join(L.tmpdir(),"codevibe-claude",t);k.existsSync(g)||k.mkdirSync(g,{recursive:!0});let c="",d=e.filename;if(n&&e.filename&&this.sessionKey)try{d=p.cryptoService.decryptContent(e.filename,this.sessionKey)}catch{d=e.filename}if(d){let y=R.extname(d);y&&(c=y)}c||(c={"image/jpeg":".jpg","image/png":".png","image/gif":".gif","image/webp":".webp","image/heic":".heic","application/pdf":".pdf"}[e.type]||".bin");let u=`attachment-${e.id}${c}`,l=R.join(g,u);return k.writeFileSync(l,a),s.info("Attachment saved to temp file",{id:e.id,filePath:l,size:a.length,wasDecrypted:n&&!!this.sessionKey}),l}catch(n){return s.error("Failed to download attachment:",{id:e.id,error:n}),null}}async executeMobilePrompt(e,t){let i=t.content||"",n=t.attachments||[];s.info("Executing mobile prompt via tmux",{sessionId:e,promptLength:i.length,attachmentCount:n.length});let r=[];if(n.length>0){s.info("Downloading attachments for prompt",{count:n.length});for(let o of n){let a=await this.downloadAttachment(o,e,t.isEncrypted);a&&r.push(a)}if(r.length>0){let o=r.map(a=>`[Attached file: ${a}]`).join(`
13
+ `);i?i=`${o}
14
14
 
15
- ${t}`:t=`${r}
15
+ ${i}`:i=`${o}
16
16
 
17
- Please analyze the attached file(s).`,n.info("Prompt updated with attachment paths",{attachmentCount:o.length,newPromptLength:t.length})}}this.trackMobilePrompt(e,t);try{if(await this.promptResponder.answerInteractivePrompt(e,t)){try{await this.appSyncClient.updateEventStatus({eventId:s.eventId,sessionId:s.sessionId,timestamp:s.timestamp,deliveryStatus:a.DeliveryStatus.EXECUTED}),n.info("Event marked as EXECUTED",{eventId:s.eventId})}catch(d){n.warn("Failed to mark event as EXECUTED",{eventId:s.eventId,error:d})}n.info("Mobile prompt sent successfully",{sessionId:e});let c=o.length>0?`Prompt with ${o.length} attachment(s) sent to Claude Code`:`Prompt "${t.substring(0,50)}${t.length>50?"...":""}" sent to Claude Code`;await this.appSyncClient.createEvent({sessionId:e,type:a.EventType.NOTIFICATION,source:a.EventSource.DESKTOP,content:c,metadata:{mobilePrompt:!0,attachmentCount:o.length}})}else n.error("Failed to send mobile prompt",{sessionId:e}),await this.appSyncClient.createEvent({sessionId:e,type:a.EventType.NOTIFICATION,source:a.EventSource.DESKTOP,content:"Failed to send prompt to Claude Code",metadata:{error:!0}})}catch(r){n.error("Failed to execute mobile prompt:",r)}}};async function de(){let l=process.argv[2]||process.env.CLAUDE_SESSION_ID;l?n.info(`Starting MCP server for session: ${l}`):n.info("Starting MCP server without initial session ID (will be set on SessionStart)");let e=new $(l);try{await e.start();let s=e.getPort();console.log(`PORT=${s}`);let t=!1,i=async o=>{if(t){n.info("Shutdown already in progress, ignoring additional signal");return}t=!0,n.info(`Received ${o} signal, stopping server...`);try{await e.stop(),n.info("Graceful shutdown completed"),process.exit(0)}catch(r){n.error("Error during shutdown:",r),process.exit(1)}};process.on("SIGINT",()=>i("SIGINT")),process.on("SIGTERM",()=>i("SIGTERM")),process.on("SIGHUP",()=>i("SIGHUP")),process.on("uncaughtException",async o=>{n.error("Uncaught exception:",o),await i("uncaughtException")}),process.on("unhandledRejection",async o=>{n.error("Unhandled rejection:",o),await i("unhandledRejection")})}catch(s){n.error("Failed to start MCP Server:",s),process.exit(1)}}function J(l,e=3){let s=l.trim(),t=s.match(/^(\d+)$/);if(t){let o=parseInt(t[1]);if(o>=1&&o<=e)return{action:"select_option",option:t[1]}}let i=s.match(/^(\d+)[,.:;\-\s\n]+(.+)$/s);if(i){let o=parseInt(i[1]);if(o>=1&&o<=e)return{action:"option_with_followup",option:i[1],followUpText:i[2].trim()}}return{action:"send_as_response"}}de().catch(l=>{n.error("Unhandled error in main:",l),process.exit(1)});0&&(module.exports={parseInteractivePromptInput});
17
+ Please analyze the attached file(s).`,s.info("Prompt updated with attachment paths",{attachmentCount:r.length,newPromptLength:i.length})}}this.trackMobilePrompt(e,i);try{if(await this.promptResponder.answerInteractivePrompt(e,i)){try{await this.appSyncClient.updateEventStatus({eventId:t.eventId,sessionId:t.sessionId,timestamp:t.timestamp,deliveryStatus:p.DeliveryStatus.EXECUTED}),s.info("Event marked as EXECUTED",{eventId:t.eventId})}catch(g){s.warn("Failed to mark event as EXECUTED",{eventId:t.eventId,error:g})}s.info("Mobile prompt sent successfully",{sessionId:e});let a=r.length>0?`Prompt with ${r.length} attachment(s) sent to Claude Code`:`Prompt "${i.substring(0,50)}${i.length>50?"...":""}" sent to Claude Code`;await this.appSyncClient.createEvent({sessionId:e,type:p.EventType.NOTIFICATION,source:p.EventSource.DESKTOP,content:a,metadata:{mobilePrompt:!0,attachmentCount:r.length}})}else s.error("Failed to send mobile prompt",{sessionId:e}),await this.appSyncClient.createEvent({sessionId:e,type:p.EventType.NOTIFICATION,source:p.EventSource.DESKTOP,content:"Failed to send prompt to Claude Code",metadata:{error:!0}})}catch(o){s.error("Failed to execute mobile prompt:",o)}}};async function Se(){let m=process.argv[2]||process.env.CLAUDE_SESSION_ID;m?s.info(`Starting MCP server for session: ${m}`):s.info("Starting MCP server without initial session ID (will be set on SessionStart)");let e=new W(m);try{await e.start();let t=e.getPort();console.log(`PORT=${t}`);let i=!1,n=async r=>{if(i){s.info("Shutdown already in progress, ignoring additional signal");return}i=!0,s.info(`Received ${r} signal, stopping server...`);try{await e.stop(),s.info("Graceful shutdown completed"),process.exit(0)}catch(o){s.error("Error during shutdown:",o),process.exit(1)}};process.on("SIGINT",()=>n("SIGINT")),process.on("SIGTERM",()=>n("SIGTERM")),process.on("SIGHUP",()=>n("SIGHUP")),process.on("uncaughtException",async r=>{s.error("Uncaught exception:",r),await n("uncaughtException")}),process.on("unhandledRejection",async r=>{s.error("Unhandled rejection:",r),await n("unhandledRejection")})}catch(t){s.error("Failed to start MCP Server:",t),process.exit(1)}}function ce(m,e=3){let t=m.trim(),i=t.match(/^(\d+)$/);if(i){let r=parseInt(i[1]);if(r>=1&&r<=e)return{action:"select_option",option:i[1]}}let n=t.match(/^(\d+)[,.:;\-\s\n]+(.+)$/s);if(n){let r=parseInt(n[1]);if(r>=1&&r<=e)return{action:"option_with_followup",option:n[1],followUpText:n[2].trim()}}return{action:"send_as_response"}}process.env.JEST_WORKER_ID||Se().catch(m=>{s.error("Unhandled error in main:",m),process.exit(1)});0&&(module.exports={McpServer,parseInteractivePromptInput});
@@ -61,7 +61,26 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
61
61
  # Tool_result user messages have content blocks of type "tool_result". We
62
62
  # find the INDEX of the last real user prompt in file order, then take every
63
63
  # subsequent assistant message (they're all part of the current turn).
64
- ASSISTANT_MESSAGES=$(jq -r --slurp --arg sent "$SENT_UUIDS_CONTENT" '
64
+ #
65
+ # Bug #292 (2026-05-09): the PermissionRequest hook fires AT THE MOMENT
66
+ # Claude Code is appending the AskUserQuestion tool_use line to the
67
+ # transcript. If the hook reads the file mid-flush, jq --slurp encounters
68
+ # a partial JSON line and fails. With `2>/dev/null` swallowing the error,
69
+ # ASSISTANT_MESSAGES becomes empty and every preceding assistant text
70
+ # block (including the response that prompted the AskUserQuestion) is
71
+ # silently dropped on mobile. AskUserQuestion is most affected because
72
+ # its tool_use payload (questions/options/descriptions) is much larger
73
+ # than Edit/Write/Bash, widening the partial-write race window.
74
+ #
75
+ # Fix: detect partial write via trailing-newline check, drop last line
76
+ # ONLY when the file ends without a newline (= write in flight). When
77
+ # the file ends with a newline, all entries are fully flushed and we
78
+ # parse all of them. This preserves any fully-written final entry.
79
+ # POSIX `tail -c 1` + bash `$( )` (which strips trailing newlines) →
80
+ # empty result iff file ends with `\n`. POSIX sed; macOS + Linux safe.
81
+ # Stderr captured to log instead of /dev/null so future filter errors
82
+ # surface during debugging rather than failing silently.
83
+ read -r -d '' JQ_FILTER <<'JQEOF' || true
65
84
  # Index of the last "real" user prompt. A real prompt has .message.content
66
85
  # that is EITHER a string (simple text prompt) OR an array containing at
67
86
  # least one content block of type "text" (mixed content). Tool_result user
@@ -94,7 +113,18 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
94
113
  select(.text | length > 0) |
95
114
  "\(.uuid)\t\(.text | @base64)"
96
115
  end
97
- ' "$TRANSCRIPT_PATH" 2>/dev/null)
116
+ JQEOF
117
+ JQ_STDERR=$(mktemp)
118
+ if [ -n "$(tail -c 1 "$TRANSCRIPT_PATH")" ]; then
119
+ log "WARN" "Transcript last line is partial; dropping for jq parse"
120
+ ASSISTANT_MESSAGES=$(sed '$d' "$TRANSCRIPT_PATH" | jq -r --slurp --arg sent "$SENT_UUIDS_CONTENT" "$JQ_FILTER" 2>"$JQ_STDERR")
121
+ else
122
+ ASSISTANT_MESSAGES=$(jq -r --slurp --arg sent "$SENT_UUIDS_CONTENT" "$JQ_FILTER" "$TRANSCRIPT_PATH" 2>"$JQ_STDERR")
123
+ fi
124
+ if [ -s "$JQ_STDERR" ]; then
125
+ log "WARN" "jq parse stderr (assistant extraction): $(cat "$JQ_STDERR")"
126
+ fi
127
+ rm -f "$JQ_STDERR"
98
128
 
99
129
  if [ -n "$ASSISTANT_MESSAGES" ]; then
100
130
  while IFS=$'\t' read -r MSG_UUID MSG_TEXT_B64; do
package/hooks/stop.sh CHANGED
@@ -60,7 +60,16 @@ fi
60
60
  # Output format: tab-separated lines: uuid\ttype\tcontent
61
61
  # type is "text" for base64-encoded assistant text, "tool_use" for JSON tool uses
62
62
  # Text is base64-encoded because it may contain newlines which break line-by-line read
63
- TRANSCRIPT_EVENTS=$(jq -r --slurp --arg sent "$SENT_UUIDS_CONTENT" '
63
+ # Bug #292 (2026-05-09): a partial last line in the transcript (file is
64
+ # being concurrently appended) makes jq --slurp fail; with `2>/dev/null`
65
+ # swallowing the error, every assistant text/tool_use in the current turn
66
+ # is silently dropped on mobile. R1 round-1 corrected the initial fix:
67
+ # Stop fires AFTER the turn finalizes, so the last line is usually a fully
68
+ # written assistant entry and unconditionally dropping it would cause its
69
+ # OWN regression. Detect partial-write via trailing-newline check and only
70
+ # drop the last line when the file ends WITHOUT a newline. Stderr captured
71
+ # instead of /dev/null so future filter errors surface.
72
+ read -r -d '' JQ_FILTER <<'JQEOF' || true
64
73
  # Index of the last "real" user prompt. A real prompt has .message.content
65
74
  # that is EITHER a string (simple text prompt) OR an array containing at
66
75
  # least one content block of type "text" (mixed content). Tool_result user
@@ -105,7 +114,18 @@ TRANSCRIPT_EVENTS=$(jq -r --slurp --arg sent "$SENT_UUIDS_CONTENT" '
105
114
  "\($msg.uuid)\ttool_use\t\(. | tojson | @base64)"
106
115
  )
107
116
  end
108
- ' "$TRANSCRIPT_PATH" 2>/dev/null)
117
+ JQEOF
118
+ JQ_STDERR=$(mktemp)
119
+ if [ -n "$(tail -c 1 "$TRANSCRIPT_PATH")" ]; then
120
+ log "WARN" "Transcript last line is partial; dropping for jq parse"
121
+ TRANSCRIPT_EVENTS=$(sed '$d' "$TRANSCRIPT_PATH" | jq -r --slurp --arg sent "$SENT_UUIDS_CONTENT" "$JQ_FILTER" 2>"$JQ_STDERR")
122
+ else
123
+ TRANSCRIPT_EVENTS=$(jq -r --slurp --arg sent "$SENT_UUIDS_CONTENT" "$JQ_FILTER" "$TRANSCRIPT_PATH" 2>"$JQ_STDERR")
124
+ fi
125
+ if [ -s "$JQ_STDERR" ]; then
126
+ log "WARN" "jq parse stderr (transcript events): $(cat "$JQ_STDERR")"
127
+ fi
128
+ rm -f "$JQ_STDERR"
109
129
 
110
130
  log "DEBUG" "Transcript parsing complete"
111
131
 
@@ -1,4 +1,4 @@
1
- import { CreateEventInput, CreateSessionInput, UpdateSessionInput, UpdateEventStatusInput, Event, Session, EventSource, DeviceKey, GrantSessionKeyInput } from '../types';
1
+ import { CreateEventInput, CreateSessionInput, UpdateSessionInput, UpdateEventStatusInput, Event, Session, EventSource, DeviceKey, GrantSessionKeyInput, UpdateReviewerPolicyInput, UserReviewerPolicySnapshot, ApplyUserDecisionInput, UserDecisionAppliedEvent } from '../types';
2
2
  /**
3
3
  * Download URL response
4
4
  */
@@ -6,6 +6,23 @@ export interface DownloadUrlResponse {
6
6
  downloadUrl: string;
7
7
  expiresAt: string;
8
8
  }
9
+ /**
10
+ * Discriminator for the most recent `authenticateWithStoredTokens()`
11
+ * failure. Lets callers distinguish a genuine "no tokens / refresh
12
+ * rejected" outcome from a transient network failure during the
13
+ * Cognito refresh-token POST.
14
+ *
15
+ * Stage 2 round-1 Codex M1: the production refresh path returns
16
+ * `false` on every error (including network blow-ups inside
17
+ * `callCognitoRefresh`'s catch block), so the wizard's auth-vs-network
18
+ * heuristic — which only ran on caught throws — never fired in
19
+ * production. Recording the kind on every false-return path lets
20
+ * `setup-bootstrap.ts:defaultClientFactory()` route network-shaped
21
+ * refresh failures to `subscription_status_network` (per §6 row
22
+ * "transient 5xx during refresh") instead of misleading the user with
23
+ * a "not signed in" abort.
24
+ */
25
+ export type AuthFailureKind = 'no_tokens' | 'refresh_auth_rejected' | 'refresh_network';
9
26
  /**
10
27
  * AppSync GraphQL client with WebSocket subscriptions
11
28
  */
@@ -15,11 +32,30 @@ export declare class AppSyncClient {
15
32
  private currentEmail;
16
33
  private tokens;
17
34
  private activeSubscriptions;
35
+ /**
36
+ * Set by `authenticateWithStoredTokens()` on every false-return path
37
+ * (and reset to `null` on success). Read by callers (e.g., the
38
+ * wizard's `defaultClientFactory`) to discriminate auth-rejection
39
+ * vs network-failure without re-running the auth call. Stage 2
40
+ * round-1 Codex M1.
41
+ */
42
+ private lastAuthFailureKind;
43
+ /**
44
+ * Sentinel set inside `performRefresh` / `callCognitoRefresh` when
45
+ * the refresh round-trip fails with a network-shaped error (DNS,
46
+ * socket reset, fetch failed, 5xx). Reset to `false` at the start
47
+ * of each `performRefresh`. Read by `authenticateWithStoredTokens`
48
+ * to classify a `refreshTokens()=false` return as
49
+ * `refresh_network` vs `refresh_auth_rejected`. Internal — never
50
+ * exposed.
51
+ */
52
+ private lastRefreshNetworkError;
18
53
  private pendingRefresh;
19
54
  private lastRefreshFailureAt;
20
55
  private static readonly REFRESH_BACKOFF_MS;
21
56
  private deviceKeyWatcher;
22
57
  private sessionUpdateWatchers;
58
+ private userDecisionAppliedWatchers;
23
59
  private environment;
24
60
  constructor();
25
61
  /**
@@ -30,6 +66,30 @@ export declare class AppSyncClient {
30
66
  * Get the current authenticated user email
31
67
  */
32
68
  getCurrentUserEmail(): string | null;
69
+ /**
70
+ * Returns the kind of the most recent
71
+ * `authenticateWithStoredTokens()` failure, or `null` if the call
72
+ * succeeded (or has never been called).
73
+ *
74
+ * Stage 2 round-1 Codex M1. Callers (today: `setup-bootstrap.ts
75
+ * :defaultClientFactory`) use this to distinguish network blow-ups
76
+ * during the Cognito refresh-token POST from genuine auth
77
+ * rejections. The wizard maps `'refresh_network'` to
78
+ * `subscription_status_network` (don't tell a signed-in user to
79
+ * re-login when their tokens are valid and the network is broken)
80
+ * and the other two kinds to `not_signed_in` (preserves
81
+ * pre-Codex-M1 behavior).
82
+ */
83
+ getLastAuthFailureKind(): AuthFailureKind | null;
84
+ /**
85
+ * Heuristic — does the error message look like a transient network
86
+ * failure rather than an auth-token rejection? Mirrors
87
+ * `setup-bootstrap.ts:isNetworkLikeError` byte-for-byte so the same
88
+ * classifier runs both inside the client (for refresh-path
89
+ * discrimination) and at the bootstrap boundary (for caught-throw
90
+ * routing). Stage 2 round-1 Codex M1.
91
+ */
92
+ private static isNetworkLikeMessage;
33
93
  /**
34
94
  * Authenticate using stored OAuth tokens from keychain
35
95
  */
@@ -111,6 +171,25 @@ export declare class AppSyncClient {
111
171
  * Update event status
112
172
  */
113
173
  updateEventStatus(input: UpdateEventStatusInput): Promise<Event>;
174
+ /**
175
+ * Submit the user's decision on an orchestration-escalated gate (Phase
176
+ * 3.b mobile V1 response-path bridge, #305). Returns the typed
177
+ * `UserDecisionAppliedEvent` envelope so the caller can update local
178
+ * UI / state tracking BEFORE the `onApplyUserDecision` subscription
179
+ * echoes back (the design is order-agnostic across the two — see
180
+ * `PHASE-3-B-MOBILE-V1-BRIDGE-DESIGN.md` §4.4).
181
+ *
182
+ * The plugin V1 bridge (codex / claude / gemini) adds the gateId to
183
+ * its `originatedDecisions` set BEFORE awaiting this call so the
184
+ * subscription handler can suppress the self-echo regardless of
185
+ * delivery order.
186
+ *
187
+ * Audit-layer dedup at the engine keys on
188
+ * `(task_id, current_round, decision)` — same-triple retries are
189
+ * absorbed silently per §4.1; different-triple races process both
190
+ * submissions independently per §4.3 (V1 last-arrival-wins).
191
+ */
192
+ applyUserDecision(input: ApplyUserDecisionInput): Promise<UserDecisionAppliedEvent>;
114
193
  /**
115
194
  * List events for a session
116
195
  */
@@ -178,6 +257,35 @@ export declare class AppSyncClient {
178
257
  * Get attachment download URL
179
258
  */
180
259
  getAttachmentDownloadUrl(s3Key: string): Promise<DownloadUrlResponse>;
260
+ /**
261
+ * Plugin startup pushes the user's locally-detected agents
262
+ * (`CLAUDE` / `GEMINI` / `CODEX`). Idempotent — safe to call every
263
+ * launch. Backend stores in `User.availableAgents`; used later to
264
+ * derive tier-default reviewer seat assignments.
265
+ */
266
+ updateAvailableAgents(agents: Array<'CLAUDE' | 'GEMINI' | 'CODEX'>): Promise<UserReviewerPolicySnapshot>;
267
+ /**
268
+ * Persist the user's orchestration opt-in default and/or custom
269
+ * reviewer panel. Backend validates seat-count against tier, seat_id
270
+ * uniqueness + range, and role uniqueness. Throws on validation
271
+ * failure — error message is user-facing (surfaced to the
272
+ * configure-reviewers wizard).
273
+ */
274
+ updateReviewerPolicy(input: UpdateReviewerPolicyInput): Promise<UserReviewerPolicySnapshot>;
275
+ /**
276
+ * Fetch the user's subscription tier + status. Used by the Phase 3.a
277
+ * setup wizard (#190) at bootstrap to gate Free → upgrade interstitial
278
+ * and to size the seat budget (Pro=2, Max=3).
279
+ *
280
+ * Backend resolver returns a default FREE row when the user has no
281
+ * Users-table entry yet (Lambda resolver — lambda/subscription/index.ts).
282
+ * Network failure / auth expiry surface as graphqlRequest exceptions.
283
+ */
284
+ getSubscriptionStatus(): Promise<{
285
+ tier: 'FREE' | 'PRO' | 'MAX';
286
+ status: 'ACTIVE' | 'EXPIRED' | 'GRACE_PERIOD' | 'BILLING_RETRY';
287
+ expiresAt: string | null;
288
+ }>;
181
289
  /**
182
290
  * Subscribe to events for a session
183
291
  */
@@ -288,6 +396,36 @@ export declare class AppSyncClient {
288
396
  private resetSessionUpdateWatcherKeepAlive;
289
397
  private handleSessionUpdateWatcherError;
290
398
  private cleanupSessionUpdateWatcherState;
399
+ /**
400
+ * Subscribe to `onApplyUserDecision(sessionId)` per
401
+ * `PHASE-3-B-MOBILE-V1-BRIDGE-DESIGN.md` §3.2. Returns a stop()
402
+ * function the caller MUST invoke when ending the session to release
403
+ * the WebSocket. Replace-on-duplicate-sessionId semantics mirror
404
+ * `subscribeToEvents` and `watchForMobileEnd` — calling with the same
405
+ * sessionId twice stops the prior watcher.
406
+ *
407
+ * V1 contract:
408
+ * - Events from this subscription cover BOTH self-originated decisions
409
+ * (the plugin's own `applyUserDecision` call echoes back) AND
410
+ * foreign-origin decisions (mobile or another desktop session
411
+ * answered on the same sessionId).
412
+ * - The plugin distinguishes via its `originatedDecisions` set
413
+ * (populated BEFORE the mutation `await`) and routes accordingly
414
+ * per the design's §3.3 two-set model.
415
+ * - No missed-event recovery in V1 (§5.2). A WebSocket drop during a
416
+ * foreign decision leaves the plugin without the dismissal signal;
417
+ * the open agent prompt stays open until the desktop user types a
418
+ * number. Acceptable for V1 because drops are sub-percent of cross-
419
+ * device flows.
420
+ */
421
+ subscribeToOnApplyUserDecision(sessionId: string, onEvent: (event: UserDecisionAppliedEvent) => void, onError?: (error: Error) => void): {
422
+ stop: () => void;
423
+ };
424
+ private createUserDecisionAppliedWatcherConnection;
425
+ private sendOnApplyUserDecisionStart;
426
+ private resetOnApplyUserDecisionKeepAlive;
427
+ private handleOnApplyUserDecisionError;
428
+ private cleanupUserDecisionAppliedWatcherState;
291
429
  private heartbeatTimers;
292
430
  /**
293
431
  * Start periodic heartbeat for a session.
@@ -10,6 +10,7 @@ export declare const queries: {
10
10
  * check.
11
11
  */
12
12
  listSessions: string;
13
+ getSubscriptionStatus: string;
13
14
  };
14
15
  export declare const mutations: {
15
16
  createSession: string;
@@ -19,9 +20,13 @@ export declare const mutations: {
19
20
  registerDeviceKey: string;
20
21
  grantSessionKey: string;
21
22
  getAttachmentDownloadUrl: string;
23
+ updateAvailableAgents: string;
24
+ updateReviewerPolicy: string;
25
+ applyUserDecision: string;
22
26
  };
23
27
  export declare const subscriptions: {
24
28
  onEventCreated: string;
25
29
  onDeviceKeyRegistered: string;
30
+ onApplyUserDecision: string;
26
31
  onSessionUpdated: string;
27
32
  };
@@ -0,0 +1,41 @@
1
+ export type Uuid = string;
2
+ /**
3
+ * `TaskCreated` — one per task lifecycle. Identity: `(task_id, kind)`.
4
+ */
5
+ export declare function dedupKeyForTaskCreated(taskId: Uuid): string;
6
+ /**
7
+ * `TaskTerminated` — one per task lifecycle. Identity: `(task_id, kind)`.
8
+ */
9
+ export declare function dedupKeyForTaskTerminated(taskId: Uuid): string;
10
+ /**
11
+ * `ProgressEvent` — keyed on `(task_id, kind, caller_event_id)`.
12
+ *
13
+ * `callerEventId` is REQUIRED (no `Option<&str>` fallback per the
14
+ * 2f.2 §5.2 lock). Callers without a stable id must invent one
15
+ * (e.g., a UUID at emit time); silently deriving from payload
16
+ * bytes would re-introduce the dedup-drift bug the lock prevents.
17
+ */
18
+ export declare function dedupKeyForProgressEvent(taskId: Uuid, callerEventId: string): string;
19
+ /**
20
+ * `ToolUse` — keyed on `(task_id, kind, caller_event_id)`. Same
21
+ * REQUIRED-not-optional rule as `dedupKeyForProgressEvent`.
22
+ */
23
+ export declare function dedupKeyForToolUse(taskId: Uuid, callerEventId: string): string;
24
+ /**
25
+ * `DestructiveActionEscalated` — keyed on `(gate_id, kind, action_id)`.
26
+ *
27
+ * Multiple destructive actions can be escalated within one gate
28
+ * (e.g., a turn that proposes both `rm -rf` and `git push --force`).
29
+ * `actionId` is the engine's internal id for the specific
30
+ * destructive call (NOT the gate, NOT the round).
31
+ */
32
+ export declare function dedupKeyForDestructiveActionEscalated(gateId: Uuid, actionId: string): string;
33
+ /**
34
+ * `FlagBadApproval` — keyed on `(flagged_entry_id, kind)`.
35
+ *
36
+ * A user flagging the same prior approval twice should dedupe to
37
+ * one flag. The Rust formula identifies the flag by the audit
38
+ * entry being flagged (not by a synthesized "bad-approval id"
39
+ * passed alongside).
40
+ */
41
+ export declare function dedupKeyForFlagBadApproval(flaggedEntryId: Uuid): string;