@crouton-kit/crouter 0.3.57 → 0.3.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +20 -5
- package/dist/clients/attach/attach-cmd.js +2 -2
- package/dist/clients/attach/chat-view.js +5 -0
- package/dist/clients/web/__tests__/push-engine.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-engine.test.js +302 -0
- package/dist/clients/web/__tests__/push-registry.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-registry.test.js +177 -0
- package/dist/clients/web/events.d.ts +5 -0
- package/dist/clients/web/events.js +16 -0
- package/dist/clients/web/push-engine.d.ts +108 -0
- package/dist/clients/web/push-engine.js +256 -0
- package/dist/clients/web/push-registry.d.ts +89 -0
- package/dist/clients/web/push-registry.js +378 -0
- package/dist/clients/web/server.js +191 -8
- package/dist/clients/web/web-client/shared/protocol.d.ts +308 -0
- package/dist/clients/web/web-client/shared/protocol.js +8 -0
- package/dist/web-client/assets/{index-FWBlgUV2.js → index-BOlkXTJY.js} +24 -24
- package/dist/web-client/assets/index-CllMPSPq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +3 -1
- package/dist/web-client/assets/index-CKvdUlkn.css +0 -2
|
@@ -388,35 +388,50 @@ async function streamProviderAttempt(
|
|
|
388
388
|
|
|
389
389
|
const bufferedEvents: Parameters<AssistantMessageEventStream["push"]>[0][] = [];
|
|
390
390
|
let sawContent = false;
|
|
391
|
+
let committed = false;
|
|
391
392
|
let classification: ProviderErrorClassification | undefined;
|
|
392
393
|
|
|
394
|
+
// Events are buffered ONLY until this attempt commits to the provider -- i.e. until the
|
|
395
|
+
// first real content event (thinking/text/toolcall) arrives, after which rotating away is
|
|
396
|
+
// no longer possible. Before commit the buffer lets a clean pre-content rate-limit rotate
|
|
397
|
+
// to another credential with nothing yet shown. Once committed we flush and forward every
|
|
398
|
+
// subsequent event LIVE, so viewers stream token-by-token instead of receiving the whole
|
|
399
|
+
// message at once when the turn finally completes.
|
|
400
|
+
const emit = (event: Parameters<AssistantMessageEventStream["push"]>[0]): void => {
|
|
401
|
+
if (committed) outer.push(event);
|
|
402
|
+
else bufferedEvents.push(event);
|
|
403
|
+
};
|
|
393
404
|
const flush = (): void => {
|
|
394
405
|
for (const event of bufferedEvents) outer.push(event);
|
|
395
406
|
bufferedEvents.length = 0;
|
|
407
|
+
committed = true;
|
|
396
408
|
};
|
|
397
409
|
|
|
398
410
|
try {
|
|
399
411
|
for await (const event of inner) {
|
|
400
412
|
if (event.type === "error") {
|
|
401
413
|
if (isUserAbortEvent(event, options?.signal)) {
|
|
402
|
-
|
|
414
|
+
emit(event);
|
|
403
415
|
flush();
|
|
404
416
|
return { result: "fatal", attemptAt };
|
|
405
417
|
}
|
|
406
418
|
classification = classifyProviderError(event.error, { status: observedStatus, retryAfterMs: requestedRetryAfterMs });
|
|
407
419
|
if (classification.kind === "fatal" || sawContent) {
|
|
408
|
-
|
|
420
|
+
emit(event);
|
|
409
421
|
flush();
|
|
410
422
|
return { result: "fatal", attemptAt, classification };
|
|
411
423
|
}
|
|
412
424
|
break;
|
|
413
425
|
}
|
|
414
426
|
if (event.type !== "start" && event.type !== "done") sawContent = true;
|
|
415
|
-
|
|
427
|
+
emit(event);
|
|
428
|
+
// First content event: commit to this provider -- flush the buffer and switch to live
|
|
429
|
+
// forwarding so the rest of the turn streams incrementally.
|
|
430
|
+
if (sawContent && !committed) flush();
|
|
416
431
|
}
|
|
417
432
|
} catch (error) {
|
|
418
433
|
if (isUserAbortError(error, options?.signal)) {
|
|
419
|
-
|
|
434
|
+
emit({
|
|
420
435
|
type: "error",
|
|
421
436
|
reason: "aborted",
|
|
422
437
|
error: { ...toAssistantError(model, error instanceof Error ? error.message : String(error)), stopReason: "aborted" },
|
|
@@ -426,7 +441,7 @@ async function streamProviderAttempt(
|
|
|
426
441
|
}
|
|
427
442
|
classification = classifyProviderError(error, { status: observedStatus, retryAfterMs: requestedRetryAfterMs });
|
|
428
443
|
if (classification.kind === "fatal" || sawContent) {
|
|
429
|
-
|
|
444
|
+
emit({
|
|
430
445
|
type: "error",
|
|
431
446
|
reason: "error",
|
|
432
447
|
error: toAssistantError(model, error instanceof Error ? error.message : String(error)),
|
|
@@ -1715,7 +1715,7 @@ CREATE TABLE IF NOT EXISTS canvas_meta (
|
|
|
1715
1715
|
`)}function lq(e){return e.includes("```mermaid")}function TCo(e){let t=0,r=e.length;for(;t<r&&e[t].trim()==="";)t++;for(;r>t&&e[r-1].trim()==="";)r--;return e.slice(t,r)}function Kar(e,t){return!lq(e)||!yCo()?e:e.replace(ECo,(r,n)=>{try{let o=`\`\`\`mermaid
|
|
1716
1716
|
${SCo(n)}
|
|
1717
1717
|
\`\`\``,s=TCo(ACo(o,t).map(i=>i.replace(/\s+$/,"")));return s.length===0||!s.some(i=>bCo.test(i))?r:["```",...s,"```"].join(`
|
|
1718
|
-
`)}catch{return r}})}function xCo(e){let t=e.content;return typeof t=="string"?lq(t):Array.isArray(t)?t.some(r=>r?.type==="text"&&lq(r.text??"")):!1}function wUe(e,t){let r=e.content;return typeof r=="string"?lq(r)?{...e,content:Kar(r,t)}:e:!Array.isArray(r)||!xCo(e)?e:{...e,content:r.map(n=>{let o=n;return o?.type==="text"&&typeof o.text=="string"&&lq(o.text)?{...o,text:Kar(o.text,t)}:n})}}var CCo="crtr-context",ICo=e=>`\x1B[2m${e}\x1B[22m`,vCo=e=>`\x1B[2m${e}\x1B[22m`;function wCo(e){return typeof e=="object"&&e!==null&&"setExpanded"in e&&typeof e.setExpanded=="function"}var RCo=e=>`\x1B[31m${e}\x1B[39m`,xne=class{tui;container;statusContainer=new In;cwd;showImages;imageWidthCells;hideThinking;hiddenThinkingLabel;toolOutputExpanded;onFooterEvent;spinnerStyle;dimStyle;errorStyle;labelStyle=t=>`\x1B[1m${t}\x1B[22m`;streamingComponent;pendingTools=new Map;bashComponent;activityLoader;bulkMode=!1;lastAssistantText;constructor(t,r,n={}){this.tui=t,this.container=r,this.cwd=n.cwd??process.cwd(),this.showImages=n.showImages??!0,this.imageWidthCells=n.imageWidthCells??60,this.hideThinking=n.hideThinking??!1,this.hiddenThinkingLabel=n.hiddenThinkingLabel??"Thinking...",this.toolOutputExpanded=n.toolOutputExpanded??!1,this.onFooterEvent=n.onFooterEvent,this.spinnerStyle=n.palette?.active??ICo,this.dimStyle=n.palette?.muted??vCo,this.errorStyle=n.palette?.error??RCo,this.container.addChild(this.statusContainer)}applySnapshot(t){this.resetChat();let r=new Map,n=t.messages,o=n.length-1;this.container.removeChild(this.statusContainer),this.bulkMode=!0;try{for(let s=0;s<n.length;s++){let i=n[s];if(i.role==="assistant"){let a=this.userMessageText(i);a.trim()&&(this.lastAssistantText=a),s===o&&t.state.isStreaming&&i.stopReason!=="aborted"&&i.stopReason!=="error"?(this.streamingComponent=new iy(void 0,this.hideThinking,aa(),this.hiddenThinkingLabel),this.append(this.streamingComponent),this.streamingComponent.updateContent(i)):this.addMessageToChat(i);for(let u of this.assistantToolCalls(i)){let c=this.makeToolComponent(u.name,u.id,u.arguments);if(this.append(c),i.stopReason==="aborted"||i.stopReason==="error"){let p=i.stopReason==="aborted"?"Operation aborted":i.errorMessage??"Error";c.updateResult({content:[{type:"text",text:p}],isError:!0})}else r.set(u.id,c)}}else if(i.role==="toolResult"){let a=r.get(i.toolCallId);a&&(a.updateResult(i),r.delete(i.toolCallId))}else this.addMessageToChat(i)}}finally{this.bulkMode=!1,this.container.addChild(this.statusContainer)}for(let[s,i]of r)this.pendingTools.set(s,i);this.tui.requestRender()}dispose(){this.setActivity(void 0)}toggleToolsExpanded(){this.toolOutputExpanded=!this.toolOutputExpanded;for(let t of this.container.children)wCo(t)&&t.setExpanded(this.toolOutputExpanded);return this.tui.requestRender(),this.toolOutputExpanded}toggleThinking(){this.hideThinking=!this.hideThinking;for(let t of this.container.children)t instanceof iy&&t.setHideThinkingBlock(this.hideThinking);return this.tui.requestRender(),this.hideThinking}handleEvent(t){switch(t.type){case"agent_start":{this.pendingTools.clear(),this.setActivity(this.makeLoader("Working..."));break}case"agent_end":{this.streamingComponent&&(this.container.removeChild(this.streamingComponent),this.streamingComponent=void 0),this.pendingTools.clear(),this.setActivity(void 0);break}case"message_start":{t.message.role==="custom"||t.message.role==="user"?this.addMessageToChat(t.message):t.message.role==="assistant"&&(this.streamingComponent=new iy(void 0,this.hideThinking,aa(),this.hiddenThinkingLabel),this.append(this.streamingComponent),this.streamingComponent.updateContent(t.message));break}case"message_update":{if(this.streamingComponent&&t.message.role==="assistant"){this.streamingComponent.updateContent(t.message);for(let r of this.assistantToolCalls(t.message)){let n=this.pendingTools.get(r.id);if(n)n.updateArgs(r.arguments);else{let o=this.makeToolComponent(r.name,r.id,r.arguments);this.append(o),this.pendingTools.set(r.id,o)}}}break}case"message_end":{if(t.message.role!=="assistant")break;if(t.message.stopReason!=="aborted"&&t.message.stopReason!=="error"){let r=this.userMessageText(t.message);r.trim()&&(this.lastAssistantText=r)}if(this.streamingComponent){let r=t.message.stopReason,n=t.message.errorMessage;r==="aborted"&&!n&&(n="Operation aborted",t.message.errorMessage=n);let o=wUe(t.message,this.renderWidth());if(this.streamingComponent.updateContent(o),r==="aborted"||r==="error"){let s=n??"Error";for(let i of this.pendingTools.values())i.updateResult({content:[{type:"text",text:s}],isError:!0});this.pendingTools.clear()}else for(let s of this.pendingTools.values())s.setArgsComplete();this.streamingComponent=void 0}break}case"tool_execution_start":{let r=this.pendingTools.get(t.toolCallId);r||(r=this.makeToolComponent(t.toolName,t.toolCallId,t.args),this.append(r),this.pendingTools.set(t.toolCallId,r)),r.markExecutionStarted();break}case"tool_execution_update":{let r=this.pendingTools.get(t.toolCallId);r&&r.updateResult({...t.partialResult,isError:!1},!0);break}case"tool_execution_end":{let r=this.pendingTools.get(t.toolCallId);r&&(r.updateResult({...t.result,isError:t.isError}),this.pendingTools.delete(t.toolCallId));break}case"compaction_start":{let r=t.reason==="manual"?"Compacting context...":"Auto-compacting...";this.setActivity(this.makeLoader(r));break}case"compaction_end":{this.setActivity(void 0),t.aborted?this.showStatus(t.reason==="manual"?"Compaction cancelled":"Auto-compaction cancelled"):t.result?this.addMessageToChat({role:"compactionSummary",summary:t.result.summary,tokensBefore:t.result.tokensBefore,timestamp:Date.now()}):t.errorMessage&&this.showError(t.errorMessage);break}case"auto_retry_start":{let r=Math.ceil(t.delayMs/1e3);this.setActivity(this.makeLoader(`Retrying (${t.attempt}/${t.maxAttempts}) in ${r}s...`));break}case"auto_retry_end":{this.setActivity(void 0),t.success||this.showError(`Retry failed after ${t.attempt} attempts: ${t.finalError??"Unknown error"}`);break}case"queue_update":case"session_info_changed":case"thinking_level_changed":this.onFooterEvent?.(t);break;default:break}this.tui.requestRender()}bashStart(t,r){let n=new ux(t,this.tui,r);this.bashComponent=n,this.append(n),this.tui.requestRender()}bashOutput(t){this.bashComponent?.appendOutput(t),this.tui.requestRender()}bashEnd(t){this.bashComponent?.setComplete(t.exitCode,t.cancelled,t.truncated?{truncated:!0}:void 0,t.fullOutputPath),this.bashComponent=void 0,this.tui.requestRender()}addMessageToChat(t){switch(t.role){case"bashExecution":{let r=new ux(t.command,this.tui,t.excludeFromContext);t.output&&r.appendOutput(t.output),r.setComplete(t.exitCode,t.cancelled,t.truncated?{truncated:!0}:void 0,t.fullOutputPath),this.append(r);break}case"custom":{if(t.display){if(t.customType===CCo){this.append(new Ene(this.userMessageText(t),this.toolOutputExpanded,this.dimStyle,n=>this.spinnerStyle(this.labelStyle(n))));break}if(t.customType===Gar){this.append(new Sne(this.userMessageText(t),this.toolOutputExpanded,this.crtrOutputTheme()));break}if(t.customType===Yar){this.append(new Xr(1)),this.append(new Dr(this.dimStyle(`\u2500\u2500 ${this.userMessageText(t)} \u2500\u2500`),1,0)),this.append(new Xr(1));break}let r=new gv(t,void 0,aa());r.setExpanded(this.toolOutputExpanded),this.append(r)}break}case"compactionSummary":{this.append(new Xr(1));let r=new fv(t,aa());r.setExpanded(this.toolOutputExpanded),this.append(r);break}case"branchSummary":{this.append(new Xr(1));let r=new mv(t,aa());r.setExpanded(this.toolOutputExpanded),this.append(r);break}case"user":{let r=this.userMessageText(t);if(!r)break;this.chatChildCount()>0&&this.append(new Xr(1));let n=SZ(r);if(n){let o=new bv(n,aa());o.setExpanded(this.toolOutputExpanded),this.append(o),n.userMessage&&this.append(new dx(n.userMessage,aa()))}else this.append(new dx(r,aa()));break}case"assistant":{this.append(new iy(wUe(t,this.renderWidth()),this.hideThinking,aa(),this.hiddenThinkingLabel));break}default:break}}makeToolComponent(t,r,n){let o=t==="bash"?Har(n):void 0,s=new Sv(t,r,n,{showImages:this.showImages,imageWidthCells:this.imageWidthCells},o,this.tui,this.cwd);return s.setExpanded(this.toolOutputExpanded),s}append(t){if(this.bulkMode){this.container.addChild(t);return}this.container.removeChild(this.statusContainer),this.container.addChild(t),this.container.addChild(this.statusContainer)}renderWidth(){return Math.max(40,process.stdout.columns??80)}chatChildCount(){return this.container.children.filter(t=>t!==this.statusContainer).length}resetChat(){this.setActivity(void 0),this.streamingComponent=void 0,this.pendingTools.clear(),this.lastAssistantText=void 0,this.container.clear(),this.container.addChild(this.statusContainer)}makeLoader(t){return new Og(this.tui,this.spinnerStyle,this.dimStyle,t)}setActivity(t){this.activityLoader?.stop(),this.statusContainer.clear(),this.activityLoader=t,t&&this.statusContainer.addChild(t)}showStatus(t){this.append(new Xr(1)),this.append(new Dr(this.dimStyle(t),1,0))}showError(t){this.append(new Xr(1)),this.append(new Dr(this.errorStyle(t),1,0))}crtrOutputTheme(){return{fg:(t,r)=>t==="error"?this.errorStyle(r):t==="warning"||t==="accent"||t==="toolTitle"||t==="success"?this.spinnerStyle(r):t==="muted"?this.dimStyle(r):r,bg:(t,r)=>r,bold:t=>this.labelStyle(t)}}assistantToolCalls(t){let r=t.content;return Array.isArray(r)?r.filter(n=>typeof n=="object"&&n!==null&&n.type==="toolCall"):[]}getLastAssistantText(){let t=this.lastAssistantText?.trim();return t||void 0}userMessageText(t){let r=t.content;return typeof r=="string"?r:Array.isArray(r)?r.filter(n=>typeof n=="object"&&n!==null&&n.type==="text").map(n=>n.text).join(""):""}};import{spawn as r0o}from"node:child_process";import{readFileSync as n0o,unlinkSync as o0o,writeFileSync as s0o}from"node:fs";import{tmpdir as i0o}from"node:os";import{join as a0o}from"node:path";function NCo(e){return e.method==="select"||e.method==="confirm"||e.method==="input"||e.method==="editor"}var Jar={dismiss:()=>{}},kCo={anchor:"center",width:"70%",maxHeight:"80%"};function Xar(e,t,r,n){if(!NCo(t))return Jar;let o=!1,s,i,a=()=>{s?.hide();let c=i;try{c?.dispose?.()}catch{}},l=c=>{o||(o=!0,a(),r(c))},u=()=>l({type:"extension_ui_response",id:t.id,cancelled:!0});switch(t.method){case"select":i=new rE(t.title,t.options,c=>l({type:"extension_ui_response",id:t.id,value:c}),u,{tui:e,timeout:t.timeout});break;case"confirm":i=new rE(`${t.title}
|
|
1718
|
+
`)}catch{return r}})}function xCo(e){let t=e.content;return typeof t=="string"?lq(t):Array.isArray(t)?t.some(r=>r?.type==="text"&&lq(r.text??"")):!1}function wUe(e,t){let r=e.content;return typeof r=="string"?lq(r)?{...e,content:Kar(r,t)}:e:!Array.isArray(r)||!xCo(e)?e:{...e,content:r.map(n=>{let o=n;return o?.type==="text"&&typeof o.text=="string"&&lq(o.text)?{...o,text:Kar(o.text,t)}:n})}}var CCo="crtr-context",ICo=e=>`\x1B[2m${e}\x1B[22m`,vCo=e=>`\x1B[2m${e}\x1B[22m`;function wCo(e){return typeof e=="object"&&e!==null&&"setExpanded"in e&&typeof e.setExpanded=="function"}var RCo=e=>`\x1B[31m${e}\x1B[39m`,xne=class{tui;container;statusContainer=new In;cwd;showImages;imageWidthCells;hideThinking;hiddenThinkingLabel;toolOutputExpanded;onFooterEvent;spinnerStyle;dimStyle;errorStyle;labelStyle=t=>`\x1B[1m${t}\x1B[22m`;streamingComponent;pendingTools=new Map;bashComponent;activityLoader;bulkMode=!1;lastAssistantText;constructor(t,r,n={}){this.tui=t,this.container=r,this.cwd=n.cwd??process.cwd(),this.showImages=n.showImages??!0,this.imageWidthCells=n.imageWidthCells??60,this.hideThinking=n.hideThinking??!1,this.hiddenThinkingLabel=n.hiddenThinkingLabel??"Thinking...",this.toolOutputExpanded=n.toolOutputExpanded??!1,this.onFooterEvent=n.onFooterEvent,this.spinnerStyle=n.palette?.active??ICo,this.dimStyle=n.palette?.muted??vCo,this.errorStyle=n.palette?.error??RCo,this.container.addChild(this.statusContainer)}applySnapshot(t){this.resetChat();let r=new Map,n=t.messages,o=n.length-1;this.container.removeChild(this.statusContainer),this.bulkMode=!0;try{for(let s=0;s<n.length;s++){let i=n[s];if(i.role==="assistant"){let a=this.userMessageText(i);a.trim()&&(this.lastAssistantText=a),s===o&&t.state.isStreaming&&i.stopReason!=="aborted"&&i.stopReason!=="error"?(this.streamingComponent=new iy(void 0,this.hideThinking,aa(),this.hiddenThinkingLabel),this.append(this.streamingComponent),this.streamingComponent.updateContent(i)):this.addMessageToChat(i);for(let u of this.assistantToolCalls(i)){let c=this.makeToolComponent(u.name,u.id,u.arguments);if(this.append(c),i.stopReason==="aborted"||i.stopReason==="error"){let p=i.stopReason==="aborted"?"Operation aborted":i.errorMessage??"Error";c.updateResult({content:[{type:"text",text:p}],isError:!0})}else r.set(u.id,c)}}else if(i.role==="toolResult"){let a=r.get(i.toolCallId);a&&(a.updateResult(i),r.delete(i.toolCallId))}else this.addMessageToChat(i)}}finally{this.bulkMode=!1,this.container.addChild(this.statusContainer)}for(let[s,i]of r)this.pendingTools.set(s,i);t.state.isStreaming&&this.setActivity(this.makeLoader("Working...")),this.tui.requestRender()}dispose(){this.setActivity(void 0)}toggleToolsExpanded(){this.toolOutputExpanded=!this.toolOutputExpanded;for(let t of this.container.children)wCo(t)&&t.setExpanded(this.toolOutputExpanded);return this.tui.requestRender(),this.toolOutputExpanded}toggleThinking(){this.hideThinking=!this.hideThinking;for(let t of this.container.children)t instanceof iy&&t.setHideThinkingBlock(this.hideThinking);return this.tui.requestRender(),this.hideThinking}handleEvent(t){switch(t.type){case"agent_start":{this.pendingTools.clear(),this.setActivity(this.makeLoader("Working..."));break}case"agent_end":{this.streamingComponent&&(this.container.removeChild(this.streamingComponent),this.streamingComponent=void 0),this.pendingTools.clear(),this.setActivity(void 0);break}case"message_start":{t.message.role==="custom"||t.message.role==="user"?this.addMessageToChat(t.message):t.message.role==="assistant"&&(this.streamingComponent=new iy(void 0,this.hideThinking,aa(),this.hiddenThinkingLabel),this.append(this.streamingComponent),this.streamingComponent.updateContent(t.message));break}case"message_update":{if(this.streamingComponent&&t.message.role==="assistant"){this.streamingComponent.updateContent(t.message);for(let r of this.assistantToolCalls(t.message)){let n=this.pendingTools.get(r.id);if(n)n.updateArgs(r.arguments);else{let o=this.makeToolComponent(r.name,r.id,r.arguments);this.append(o),this.pendingTools.set(r.id,o)}}}break}case"message_end":{if(t.message.role!=="assistant")break;if(t.message.stopReason!=="aborted"&&t.message.stopReason!=="error"){let r=this.userMessageText(t.message);r.trim()&&(this.lastAssistantText=r)}if(this.streamingComponent){let r=t.message.stopReason,n=t.message.errorMessage;r==="aborted"&&!n&&(n="Operation aborted",t.message.errorMessage=n);let o=wUe(t.message,this.renderWidth());if(this.streamingComponent.updateContent(o),r==="aborted"||r==="error"){let s=n??"Error";for(let i of this.pendingTools.values())i.updateResult({content:[{type:"text",text:s}],isError:!0});this.pendingTools.clear()}else for(let s of this.pendingTools.values())s.setArgsComplete();this.streamingComponent=void 0}break}case"tool_execution_start":{let r=this.pendingTools.get(t.toolCallId);r||(r=this.makeToolComponent(t.toolName,t.toolCallId,t.args),this.append(r),this.pendingTools.set(t.toolCallId,r)),r.markExecutionStarted();break}case"tool_execution_update":{let r=this.pendingTools.get(t.toolCallId);r&&r.updateResult({...t.partialResult,isError:!1},!0);break}case"tool_execution_end":{let r=this.pendingTools.get(t.toolCallId);r&&(r.updateResult({...t.result,isError:t.isError}),this.pendingTools.delete(t.toolCallId));break}case"compaction_start":{let r=t.reason==="manual"?"Compacting context...":"Auto-compacting...";this.setActivity(this.makeLoader(r));break}case"compaction_end":{this.setActivity(void 0),t.aborted?this.showStatus(t.reason==="manual"?"Compaction cancelled":"Auto-compaction cancelled"):t.result?this.addMessageToChat({role:"compactionSummary",summary:t.result.summary,tokensBefore:t.result.tokensBefore,timestamp:Date.now()}):t.errorMessage&&this.showError(t.errorMessage);break}case"auto_retry_start":{let r=Math.ceil(t.delayMs/1e3);this.setActivity(this.makeLoader(`Retrying (${t.attempt}/${t.maxAttempts}) in ${r}s...`));break}case"auto_retry_end":{this.setActivity(void 0),t.success||this.showError(`Retry failed after ${t.attempt} attempts: ${t.finalError??"Unknown error"}`);break}case"queue_update":case"session_info_changed":case"thinking_level_changed":this.onFooterEvent?.(t);break;default:break}this.tui.requestRender()}bashStart(t,r){let n=new ux(t,this.tui,r);this.bashComponent=n,this.append(n),this.tui.requestRender()}bashOutput(t){this.bashComponent?.appendOutput(t),this.tui.requestRender()}bashEnd(t){this.bashComponent?.setComplete(t.exitCode,t.cancelled,t.truncated?{truncated:!0}:void 0,t.fullOutputPath),this.bashComponent=void 0,this.tui.requestRender()}addMessageToChat(t){switch(t.role){case"bashExecution":{let r=new ux(t.command,this.tui,t.excludeFromContext);t.output&&r.appendOutput(t.output),r.setComplete(t.exitCode,t.cancelled,t.truncated?{truncated:!0}:void 0,t.fullOutputPath),this.append(r);break}case"custom":{if(t.display){if(t.customType===CCo){this.append(new Ene(this.userMessageText(t),this.toolOutputExpanded,this.dimStyle,n=>this.spinnerStyle(this.labelStyle(n))));break}if(t.customType===Gar){this.append(new Sne(this.userMessageText(t),this.toolOutputExpanded,this.crtrOutputTheme()));break}if(t.customType===Yar){this.append(new Xr(1)),this.append(new Dr(this.dimStyle(`\u2500\u2500 ${this.userMessageText(t)} \u2500\u2500`),1,0)),this.append(new Xr(1));break}let r=new gv(t,void 0,aa());r.setExpanded(this.toolOutputExpanded),this.append(r)}break}case"compactionSummary":{this.append(new Xr(1));let r=new fv(t,aa());r.setExpanded(this.toolOutputExpanded),this.append(r);break}case"branchSummary":{this.append(new Xr(1));let r=new mv(t,aa());r.setExpanded(this.toolOutputExpanded),this.append(r);break}case"user":{let r=this.userMessageText(t);if(!r)break;this.chatChildCount()>0&&this.append(new Xr(1));let n=SZ(r);if(n){let o=new bv(n,aa());o.setExpanded(this.toolOutputExpanded),this.append(o),n.userMessage&&this.append(new dx(n.userMessage,aa()))}else this.append(new dx(r,aa()));break}case"assistant":{this.append(new iy(wUe(t,this.renderWidth()),this.hideThinking,aa(),this.hiddenThinkingLabel));break}default:break}}makeToolComponent(t,r,n){let o=t==="bash"?Har(n):void 0,s=new Sv(t,r,n,{showImages:this.showImages,imageWidthCells:this.imageWidthCells},o,this.tui,this.cwd);return s.setExpanded(this.toolOutputExpanded),s}append(t){if(this.bulkMode){this.container.addChild(t);return}this.container.removeChild(this.statusContainer),this.container.addChild(t),this.container.addChild(this.statusContainer)}renderWidth(){return Math.max(40,process.stdout.columns??80)}chatChildCount(){return this.container.children.filter(t=>t!==this.statusContainer).length}resetChat(){this.setActivity(void 0),this.streamingComponent=void 0,this.pendingTools.clear(),this.lastAssistantText=void 0,this.container.clear(),this.container.addChild(this.statusContainer)}makeLoader(t){return new Og(this.tui,this.spinnerStyle,this.dimStyle,t)}setActivity(t){this.activityLoader?.stop(),this.statusContainer.clear(),this.activityLoader=t,t&&this.statusContainer.addChild(t)}showStatus(t){this.append(new Xr(1)),this.append(new Dr(this.dimStyle(t),1,0))}showError(t){this.append(new Xr(1)),this.append(new Dr(this.errorStyle(t),1,0))}crtrOutputTheme(){return{fg:(t,r)=>t==="error"?this.errorStyle(r):t==="warning"||t==="accent"||t==="toolTitle"||t==="success"?this.spinnerStyle(r):t==="muted"?this.dimStyle(r):r,bg:(t,r)=>r,bold:t=>this.labelStyle(t)}}assistantToolCalls(t){let r=t.content;return Array.isArray(r)?r.filter(n=>typeof n=="object"&&n!==null&&n.type==="toolCall"):[]}getLastAssistantText(){let t=this.lastAssistantText?.trim();return t||void 0}userMessageText(t){let r=t.content;return typeof r=="string"?r:Array.isArray(r)?r.filter(n=>typeof n=="object"&&n!==null&&n.type==="text").map(n=>n.text).join(""):""}};import{spawn as r0o}from"node:child_process";import{readFileSync as n0o,unlinkSync as o0o,writeFileSync as s0o}from"node:fs";import{tmpdir as i0o}from"node:os";import{join as a0o}from"node:path";function NCo(e){return e.method==="select"||e.method==="confirm"||e.method==="input"||e.method==="editor"}var Jar={dismiss:()=>{}},kCo={anchor:"center",width:"70%",maxHeight:"80%"};function Xar(e,t,r,n){if(!NCo(t))return Jar;let o=!1,s,i,a=()=>{s?.hide();let c=i;try{c?.dispose?.()}catch{}},l=c=>{o||(o=!0,a(),r(c))},u=()=>l({type:"extension_ui_response",id:t.id,cancelled:!0});switch(t.method){case"select":i=new rE(t.title,t.options,c=>l({type:"extension_ui_response",id:t.id,value:c}),u,{tui:e,timeout:t.timeout});break;case"confirm":i=new rE(`${t.title}
|
|
1719
1719
|
${t.message}`,["Yes","No"],c=>l({type:"extension_ui_response",id:t.id,confirmed:c==="Yes"}),u,{tui:e,timeout:t.timeout});break;case"input":i=new cx(t.title,t.placeholder,c=>l({type:"extension_ui_response",id:t.id,value:c}),u,{tui:e,timeout:t.timeout});break;case"editor":i=new _v(e,n??new kg(SE),t.title,t.prefill,c=>l({type:"extension_ui_response",id:t.id,value:c}),u);break;default:return Jar}return s=e.showOverlay(i,kCo),{dismiss:()=>{o||(o=!0,a())}}}import{spawnSync as rcr}from"node:child_process";import{mkdirSync as OCo,readFileSync as PCo,unlinkSync as DCo,writeFileSync as MCo}from"node:fs";import{tmpdir as ncr}from"node:os";import{join as RUe}from"node:path";var Zar=1568,NUe=3*1024*1024,ocr=64*1024*1024,scr=3e3;function icr(e){let t=RUe(ncr(),"crtr-clip-images");OCo(t,{recursive:!0});let r=e.mimeType==="image/jpeg"?"jpg":"png",n=RUe(t,`paste-${Date.now()}-${process.pid}.${r}`);return MCo(n,Buffer.from(e.data,"base64")),n}async function acr(){let e=LCo(),t=e.image;if(!t)return e.note?{note:e.note}:null;let r=await ID(new Uint8Array(t.bytes),t.mimeType,{maxWidth:Zar,maxHeight:Zar,maxBytes:NUe});if(r)return{image:{type:"image",data:r.data,mimeType:r.mimeType},note:r.wasResized?vD(r):void 0};let n=await PQ(t.bytes.toString("base64"),t.mimeType);return ecr(n?{type:"image",data:n.data,mimeType:n.mimeType}:{type:"image",data:t.bytes.toString("base64"),mimeType:t.mimeType})}function ecr(e){let t=Buffer.byteLength(e.data);if(t<=NUe)return{image:e};let r=n=>Math.round(n/(1024*1024));return{note:`Image not attached: ${r(t)} MiB exceeds the ${r(NUe)} MiB attach limit (clipboard resizer unavailable)`}}function LCo(){return process.platform==="darwin"?BCo(tcr,$Co):FCo(tcr,zCo())}function BCo(e,t){let r=e("pngpaste",["-"]);return r.bytes?{image:{bytes:r.bytes,mimeType:"image/png"}}:t()}function FCo(e,t){let r=!1;if(t){let n=e("wl-paste",["--type","image/png","--no-newline"]);if(n.bytes)return{image:{bytes:n.bytes,mimeType:"image/png"}};n.missing||(r=!0)}for(let n of["image/png","image/jpeg"]){let o=e("xclip",["-selection","clipboard","-t",n,"-o"]);if(o.bytes)return{image:{bytes:o.bytes,mimeType:n}};o.missing||(r=!0)}return r?{}:{note:t?"No clipboard tool found \u2014 install wl-clipboard (wl-paste) to paste images":"No clipboard tool found \u2014 install xclip to paste images"}}var UCo=`on run argv
|
|
1720
1720
|
set outPath to item 1 of argv
|
|
1721
1721
|
try
|
|
@@ -1746,7 +1746,7 @@ The viewer resumes when it exits.
|
|
|
1746
1746
|
${S}`,"1"),C=parseInt(v,10)-1;return A.options[C]?.id},signal:E.signal}).then(()=>{h||(n(`Logged in to ${m.name}`),r(),g())}).catch(A=>{A?.name!=="AbortError"&&n(`Login error: ${String(A?.message??A)}`),g()})}else E.showPrompt(`Enter API key for ${m.name}:`,"sk-...").then(y=>{h||(y.trim()?(a.set(f,{type:"api_key",key:y.trim()}),n(`API key saved for ${m.name}`),r()):n("No key entered \u2014 login cancelled"),g())}).catch(()=>g())},d=new px("login",a,u,p,()=>s(),c);return{component:d,focus:d}}function Fcr(e,t,r,n){let{authStorage:o,registry:s}=Mcr(e),i=Lcr(o,s),a=c=>o.getAuthStatus(c),l=c=>{let p=i.find(d=>d.id===c);o.logout(c),n(),r(`Logged out of ${p?.name??c}`),t()},u=new px("logout",o,i,l,()=>n(),a);return{component:u,focus:u}}var uq=[46,92,190],pq=[166,98,24];function dq(e,t,r){return[Math.round(e[0]+(t[0]-e[0])*r),Math.round(e[1]+(t[1]-e[1])*r),Math.round(e[2]+(t[2]-e[2])*r)]}var Ucr={off:void 0,minimal:dq(uq,pq,0/4),low:dq(uq,pq,1/4),medium:dq(uq,pq,2/4),high:dq(uq,pq,3/4),xhigh:dq(uq,pq,4/4)},Rne=e=>`\x1B[7m ${e} \x1B[27m`;function $cr(e,t){let r=e===void 0?void 0:Ucr[e];if(r===void 0)return t;let[n,o,s]=r;return i=>`\x1B[38;2;${n};${o};${s}m${i}\x1B[39m`}function zcr(e,t){let r=e===void 0?void 0:Ucr[e];if(r===void 0)return t;let[n,o,s]=r;return i=>`\x1B[48;2;${n};${o};${s}m\x1B[97m\x1B[1m ${i} \x1B[0m`}function D0o(e,t,r,n,o){let s=t?n(Xn(t,Math.max(1,e-4),"\u2026")):"",i=dn(s),a=e-i,l="";r&&a>4&&(l=dn(r)<=a?r:Xn(r,a,"\u2026"));let u=Math.max(0,e-i-dn(l));return s+o("\u2500".repeat(u))+l}var Nne=class extends hv{km;constructor(t,r,n,o){super(t,r,n,o),this.km=n}handleInput(t){if(!this.isShowingAutocomplete()&&this.km.matches(t,"tui.input.newLine")){this.insertTextAtCursor(`
|
|
1747
1747
|
`);return}super.handleInput(t)}title="";info="";mode="";titleStyle=Rne;modeStyle=Rne;render(t){let r=super.render(t);if((this.title||this.info)&&r.length>0&&(r[0]=D0o(t,this.title,this.info,this.titleStyle,n=>this.borderColor(n))),this.mode&&r.length>0){let n=this.modeStyle(this.mode),o=dn(n);if(o+2<t){let s=r.length-1;r[s]=Xn(r[s],t-o,"")+n}}return r}};import{execFile as M0o}from"node:child_process";import{basename as L0o}from"node:path";function B0o(e,t){let r=t.split(`
|
|
1748
1748
|
`).filter(o=>o.length>0),n={dir:e,dirty:!1,ahead:0,behind:0};for(let o of r)if(o.startsWith("## ")){let s=o.slice(3);if(!s.startsWith("HEAD (no branch)")){let i=s.split(/\.\.\.| /,1)[0];i&&(n.branch=i)}n.ahead=Number(/ahead (\d+)/.exec(s)?.[1]??0),n.behind=Number(/behind (\d+)/.exec(s)?.[1]??0)}else n.dirty=!0;return n}function Qcr(e,t){let r=L0o(e)||e;M0o("git",["-C",e,"status","--porcelain=v1","--branch"],{timeout:2e3,maxBuffer:4*1024*1024},(n,o)=>{if(n){t({dir:r,dirty:!1,ahead:0,behind:0});return}t(B0o(r,o))})}import{EventEmitter as F0o}from"node:events";import{randomUUID as U0o}from"node:crypto";import{createConnection as qcr}from"node:net";var Gcr=1e4;function Vcr(e){return e===null?!0:e.status==="done"||e.status==="dead"||e.status==="canceled"}var mq=class extends Error{constructor(r){super(`node ${r} has no running broker \u2014 focus or revive it first`);this.nodeId=r;this.name="BrokerUnavailableError"}nodeId},kne=class extends F0o{constructor(r){super();this.nodeId=r}nodeId;socket;decoder=new GM(Ire);closeEmitted=!1;pending=new Map;redialFaultRecorded=!1;recordSocketFault(r,n){if(r==="redial"){if(this.redialFaultRecorded)return;this.redialFaultRecorded=!0}let o=uUe("viewer\u2194broker",n);Cre(this.nodeId,{link:"viewer\u2194broker",op:r,kind:o.kind,retry:r==="redial"?{disposition:"auto",by:"client"}:{disposition:"manual"},message:n instanceof Error?n.message:String(n)})}request(r){let n=U0o();return new Promise((o,s)=>{let i=this.socket;if(i===void 0||i.destroyed){s(new Error(`cannot issue '${r.type}': no live broker connection`));return}let a=setTimeout(()=>{this.pending.delete(n),s(new Error(`request '${r.type}' timed out after ${Gcr}ms`))},Gcr);typeof a.unref=="function"&&a.unref(),this.pending.set(n,{resolve:o,reject:s,timer:a}),this.send({...r,id:n})})}rejectAllPending(r){for(let{reject:n,timer:o}of this.pending.values())clearTimeout(o),n(new Error(r));this.pending.clear()}get socketPath(){return wv(this.nodeId)}connect(){let r=qcr(this.socketPath);this.socket=r,r.on("connect",()=>{this.redialFaultRecorded=!1,zQ(this.nodeId,{link:"viewer\u2194broker"}),this.emit("connect")}),r.on("data",n=>this.onData(n)),r.on("error",n=>this.onError(n,"connect")),r.on("close",()=>this.onClose())}redial(){return this.destroy(),this.decoder=new GM(Ire),this.closeEmitted=!1,new Promise((r,n)=>{let o=qcr(this.socketPath);this.socket=o;let s=!1;o.on("connect",()=>{s||(s=!0,this.redialFaultRecorded=!1,zQ(this.nodeId,{link:"viewer\u2194broker"}),this.emit("connect"),r())}),o.on("data",i=>this.onData(i)),o.on("error",i=>{if(s){this.onError(i,"redial");return}s=!0,this.recordSocketFault("redial",i);try{o.destroy()}catch{}n(i)}),o.on("close",()=>this.onClose())})}send(r){let n=this.socket;if(!(n===void 0||n.destroyed))try{n.write(kv(r))}catch{}}close(){this.destroy()}onData(r){let n;try{n=this.decoder.push(r)}catch(o){let s=o instanceof Nv?`broker sent an oversized frame (${o.message}) \u2014 disconnecting`:`failed to decode a broker frame: ${String(o)}`;this.rejectAllPending(s),this.emitError(new Error(s)),this.destroy();return}for(let o of n){let s=o;if(s.type==="data"){let i=typeof s.id=="string"?this.pending.get(s.id):void 0;i&&(this.pending.delete(s.id),clearTimeout(i.timer),i.resolve(s));continue}if(s.type==="error"&&typeof s.id=="string"&&this.pending.has(s.id)){let i=this.pending.get(s.id);this.pending.delete(s.id),clearTimeout(i.timer),i.reject(new Error(s.message||`request failed: ${s.code}`));continue}this.emit("frame",s)}}onError(r,n){this.recordSocketFault(n,r),r.code==="ECONNREFUSED"||r.code==="ENOENT"?this.emitError(new mq(this.nodeId)):this.emitError(new Error(`view socket error: ${r.message}`))}onClose(){this.closeEmitted||(this.closeEmitted=!0,this.rejectAllPending("broker connection closed"),this.emit("close"))}destroy(){let r=this.socket;if(r!==void 0&&!r.destroyed)try{r.destroy()}catch{}}emitError(r){this.listenerCount("error")>0&&this.emit("error",r)}};if(process.env.PI_PACKAGE_DIR===void 0)try{let e=import.meta.resolve("@earendil-works/pi-coding-agent");process.env.PI_PACKAGE_DIR=Wcr(Wcr(V0o(e)))}catch{}function W0o(e,t){$0o("crtr",["canvas","attention","map","--view",e,"--json"],{timeout:2500,maxBuffer:4*1024*1024},(r,n)=>{if(!r)try{let o=JSON.parse(n.trim());t(o.counts??{})}catch{}})}var One=["normal","spec","plan"];function FUe(e,t){return Pne(GBe(e),"mode-switch",t)}function j0o(e,t){return Pne(FUe(e,t),"request.json")}function H0o(e,t){return Pne(FUe(e,t),"current")}function Y0o(e,t){try{let r=Q0o(H0o(e,t),"utf8").trim();return One.includes(r)?r:void 0}catch{return}}function K0o(e,t,r){let n=FUe(e,t);z0o(n,{recursive:!0});let o=jcr(),s=Pne(n,`request.${o}.tmp`);G0o(s,`${JSON.stringify({mode:r,token:o,source:"attach"})}
|
|
1749
|
-
`),q0o(s,j0o(e,t))}function J0o(e){let t=One.indexOf(e);return One[(t+1)%One.length]}function X0o(e){return e==="spec"?t=>`\x1B[44m\x1B[97m\x1B[1m ${t} \x1B[0m`:e==="plan"?t=>`\x1B[48;2;228;152;68m\x1B[30m\x1B[1m ${t} \x1B[0m`:t=>t}function Z0o(e){let t=e.trimStart();return t.startsWith("!!")?2:t.startsWith("!")?1:0}function eIo(e,t,r){return e===2?{borderColor:r.bashModeAlt,titleStyle:n=>`\x1B[102m\x1B[30m\x1B[1m ${n} \x1B[0m`}:e===1?{borderColor:r.bashMode,titleStyle:n=>`\x1B[42m\x1B[97m\x1B[1m ${n} \x1B[0m`}:{borderColor:$cr(t,r.border),titleStyle:zcr(t,Rne)}}function tIo(e,t,r,n=(o,s,i)=>new rw([...o],s,i)){return n([...hcr(r)],e,t)}async function rIo(e,t){let r=Hi(e);if(r===null)throw new Cv({error:"not_found",message:`no node: ${e}`,received:e,next:"List nodes with `crtr node inspect list`."});let n=new kne(e);await new Promise((Ge,St)=>{let or=()=>{zr(),Ge()},Mr=an=>{zr(),St(an)},zr=()=>{n.off("connect",or),n.off("error",Mr)};n.on("connect",or),n.on("error",Mr),n.connect()}).catch(Ge=>{throw Ge instanceof mq?new Cv({error:"no_broker",message:Ge.message,received:e,next:"Focus or revive the node first \u2014 attach needs a running headless broker."}):new Cv({error:"socket_error",message:`could not attach to node ${e}: ${String(Ge?.message??Ge)}`,received:e,next:"Check the node has a running headless broker."})});let o=process.env.TMUX_PANE??rq()?.pane;if(o!==void 0&&o!=="")try{lne(o,"@crtr_node",e)}catch{}let s=()=>{if(o!==void 0&&o!=="")try{lne(o,"@crtr_node","")}catch{}};kcr({cwd:r.cwd});let i=Ocr(),a=Icr();await wcr(a);let l=await Ncr();if(tq())try{nq()}catch{}let u=new vE,c=new xE(u),p=new In,d=new In,f=new In,m=new In,h=new In,g=new In,E=new In;d.addChild(new Fo(i.border));let y={borderColor:i.border,selectList:Zd()},A=new Nne(c,y,a,{paddingX:1}),S=Y0o(r.cwd,e)??"normal",v=Ge=>{A.setAutocompleteProvider(tIo(r.cwd,l,Ge))},C=()=>{A.mode=S==="normal"?"":S,A.modeStyle=X0o(S)};v(),C();let I=new xne(c,p,{cwd:r.cwd,palette:i}),R=jcr(),N=t?"observer":"controller",F,j,V=0,q="",ae,be=Ge=>Ge>=1e3?`${(Ge/1e3).toFixed(Ge>=1e4?0:1)}k`:`${Ge}`,ye=()=>{let Ge=[];N!=="controller"&&Ge.push(i.muted("\u25CB read-only")),V===2?Ge.unshift(i.bashModeAlt("\u25CF bash \u2014 output hidden from agent")):V===1&&Ge.unshift(i.bashMode("\u25CF bash \u2014 output added to context")),j?.model&&Ge.push(i.info(j.model)),j?.isStreaming&&Ge.push(i.active("\u27F3")),ae!==void 0&&Ge.push(i.muted(`${be(ae)} ctx`)),j&&j.pendingMessageCount>0&&Ge.push(i.muted(`queued ${j.pendingMessageCount}`));let St=Ge.join(i.muted(" \xB7 ")),or=q?i.warning(`\u26A0 ${q}`):"",Mr=Math.max(1,(process.stdout.columns??80)-2),zr=xx(or),an=or?Math.max(0,Mr-zr-1):Mr,ot=xx(St)>an?Xn(St,an,"\u2026"):St,Mt=Math.max(or?1:0,Mr-xx(ot)-zr),Cr=or?ot+" ".repeat(Mt)+or:ot;E.clear(),E.addChild(new Dr(Cr,1,0)),c.requestRender()},ce=3e3,W,se=(Ge,St)=>{q=Ge,W!==void 0&&(clearTimeout(W),W=void 0),Ge&&!St?.sticky&&(W=setTimeout(()=>{W=void 0,q="",ye()},ce)),ye()},Te={},je=()=>{let{managers:Ge,reports:St}=xcr(e,Te,i);f.clear();for(let or of Ge)f.addChild(new Dr(or,1,0));m.clear();for(let or of St)m.addChild(new Dr(or,1,0));c.requestRender()},Ye=new vne(c,e,()=>Te,i),Le=()=>{je(),Ye.refresh()},oe=Ge=>{let St=[i.muted(Ge.dir)];Ge.branch&&St.push(i.info(`\u2387 ${Ge.branch}`));let or=[];Ge.dirty&&or.push(i.warning("\u25CF")),Ge.ahead>0&&or.push(i.active(`\u21E1${Ge.ahead}`)),Ge.behind>0&&or.push(i.active(`\u21E3${Ge.behind}`)),or.length>0&&St.push(or.join("")),A.info=` ${St.join(i.muted(" \xB7 "))} `,c.requestRender()},de=()=>Qcr(r.cwd,oe),ie=[],ee=()=>{if(h.clear(),ie.length>0){let Ge=Math.max(1,(process.stdout.columns??80)-4);h.addChild(new Dr(i.muted(`queued (${ie.length}):`),1,0));for(let St of ie){let or=St.replace(/\s+/g," ").trim();h.addChild(new Dr(i.faint(` \u22EF ${Xn(or,Ge,"\u2026")}`),1,0))}}c.requestRender()},ne=new Ine(c,A,a,{onCommand:Ge=>n.send(Ge),onDialogResponse:Ge=>n.send(Ge),onNotice:Ge=>se(Ge),nodeId:e,onGraph:()=>Ye.toggle(),onQuit:()=>rt("detach"),onCopy:()=>H(),onToggleToolsExpand:()=>se(I.toggleToolsExpanded()?"Tool output: expanded":"Tool output: collapsed"),onToggleThinking:()=>se(I.toggleThinking()?"Thinking blocks: hidden":"Thinking blocks: visible"),onRequest:Ge=>n.request(Ge),onMountPicker:Ge=>{g.addChild(Ge),c.requestRender()},onUnmountPicker:()=>{g.clear(),c.requestRender()},openLoginPicker:()=>{let Ge=F??oL();ne.openAuthPicker(St=>Bcr(c,Ge,()=>n.send({type:"reload_auth"}),or=>se(or),St))},openLogoutPicker:()=>{let Ge=F??oL();ne.openAuthPicker(St=>Fcr(Ge,()=>n.send({type:"reload_auth"}),or=>se(or),St.close))}}),H=()=>{let Ge=I.getLastAssistantText();if(!Ge){se("Nothing to copy \u2014 no assistant message yet");return}se(MUe(Ge)?"Copied last message to clipboard":"Copy failed \u2014 no clipboard tool found")},te=()=>{let Ge=eIo(V,j?.thinkingLevel,i);A.borderColor=Ge.borderColor,A.titleStyle=Ge.titleStyle};A.onChange=Ge=>{let St=Z0o(Ge);St!==V&&(V=St,te(),ye(),c.requestRender())};let me=Ge=>{j=Ge,ne.setState(Ge),A.title=Ge.sessionName?Ge.sessionName:"",te(),ye(),Le()},Ie=Ge=>{j!==void 0&&me({...j,...Ge})},ve=Ge=>{switch(Ge.type){case"welcome":{N=Ge.role,F=Ge.agentDir,I.applySnapshot(Ge.snapshot);let St=Ge.snapshot.stats.contextUsage;St&&St.tokens!=null&&(ae=St.tokens),me(Ge.snapshot.state),n.send({type:"get_commands"}),Ge.pending_dialog!=null&&ne.attachDialog(Ge.pending_dialog);break}case"control_changed":{N=Ge.controller_id===R?"controller":"observer",ye();break}case"model_changed":{Ie({model:Ge.model});break}case"error":{Ge.code==="not_controller"?se("read-only \u2014 another viewer is the controller"):se(`error: ${Ge.message}`);break}case"ack":{if(Ge.for==="get_commands"&&Ge.ok&&Ge.detail){try{let St=JSON.parse(Ge.detail);v(St)}catch{}break}if(Ge.for==="navigate_tree"&&Ge.ok){Ge.detail&&(A.setText(Ge.detail),c.requestRender());break}if(Ge.for==="share"){Ge.ok&&Ge.detail?(se(`Shared: ${Ge.detail}`),MUe(Ge.detail)):Ge.ok||se(`share failed: ${Ge.detail??"unknown error"}`);break}if(Ge.for==="clone"&&!Ge.ok){se(`clone failed: ${Ge.detail??"unknown error"}`);break}if(!Ge.ok){se(`command failed: ${Ge.for}${Ge.detail?` \u2014 ${Ge.detail}`:""}`);break}Ge.for==="reload"&&se("Reloaded keybindings, extensions, skills, prompts, and themes");break}case"extension_ui_request":{ne.attachDialog(Ge);break}case"bash_start":I.bashStart(Ge.command,Ge.excludeFromContext);break;case"bash_output":I.bashOutput(Ge.chunk);break;case"bash_end":I.bashEnd(Ge);break;default:{let St=Ge;if(I.handleEvent(St),St.type==="message_end"&&St.message.role==="assistant"){let or=St.message.usage;ae=or.input+or.cacheRead+or.cacheWrite,ye()}St.type==="agent_start"?Ie({isStreaming:!0}):St.type==="agent_end"?Ie({isStreaming:!1}):St.type==="session_info_changed"?Ie({sessionName:St.name}):St.type==="thinking_level_changed"?(Ie({thinkingLevel:St.level}),se(`Thinking level: ${St.level}`)):St.type==="queue_update"&&(ie=[...St.steering,...St.followUp],ee(),Ie({pendingMessageCount:ie.length}));break}}},he=!1,Se=()=>{if(!he){he=!0;try{c.stop()}catch{}}};process.once("exit",Se);let Re=!1,Ce,ke,xe=new Promise(Ge=>{ke=Ge}),J,rt=Ge=>{if(!Re){Re=!0,J!==void 0&&clearInterval(J),W!==void 0&&clearTimeout(W),Ye.close(),s(),Ar(),Ge==="detach"&&n.send({type:"bye"});try{I.dispose()}catch{}if(n.close(),Se(),Ge==="detach")try{aUe(e)}catch{}if(Ge==="broker-gone"){let St=Ce??`broker gone \u2014 node ${e} is no longer running its engine. Re-focus to reattach.`;process.stderr.write(`
|
|
1749
|
+
`),q0o(s,j0o(e,t))}function J0o(e){let t=One.indexOf(e);return One[(t+1)%One.length]}function X0o(e){return e==="spec"?t=>`\x1B[44m\x1B[97m\x1B[1m ${t} \x1B[0m`:e==="plan"?t=>`\x1B[48;2;228;152;68m\x1B[30m\x1B[1m ${t} \x1B[0m`:t=>t}function Z0o(e){let t=e.trimStart();return t.startsWith("!!")?2:t.startsWith("!")?1:0}function eIo(e,t,r){return e===2?{borderColor:r.bashModeAlt,titleStyle:n=>`\x1B[102m\x1B[30m\x1B[1m ${n} \x1B[0m`}:e===1?{borderColor:r.bashMode,titleStyle:n=>`\x1B[42m\x1B[97m\x1B[1m ${n} \x1B[0m`}:{borderColor:$cr(t,r.border),titleStyle:zcr(t,Rne)}}function tIo(e,t,r,n=(o,s,i)=>new rw([...o],s,i)){return n([...hcr(r)],e,t)}async function rIo(e,t){let r=Hi(e);if(r===null)throw new Cv({error:"not_found",message:`no node: ${e}`,received:e,next:"List nodes with `crtr node inspect list`."});let n=new kne(e);await new Promise((Ge,St)=>{let or=()=>{zr(),Ge()},Mr=an=>{zr(),St(an)},zr=()=>{n.off("connect",or),n.off("error",Mr)};n.on("connect",or),n.on("error",Mr),n.connect()}).catch(Ge=>{throw Ge instanceof mq?new Cv({error:"no_broker",message:Ge.message,received:e,next:"Focus or revive the node first \u2014 attach needs a running headless broker."}):new Cv({error:"socket_error",message:`could not attach to node ${e}: ${String(Ge?.message??Ge)}`,received:e,next:"Check the node has a running headless broker."})});let o=process.env.TMUX_PANE??rq()?.pane;if(o!==void 0&&o!=="")try{lne(o,"@crtr_node",e)}catch{}let s=()=>{if(o!==void 0&&o!=="")try{lne(o,"@crtr_node","")}catch{}};kcr({cwd:r.cwd});let i=Ocr(),a=Icr();await wcr(a);let l=await Ncr();if(tq())try{nq()}catch{}let u=new vE,c=new xE(u),p=new In,d=new In,f=new In,m=new In,h=new In,g=new In,E=new In;d.addChild(new Fo(i.border));let y={borderColor:i.border,selectList:Zd()},A=new Nne(c,y,a,{paddingX:1}),S=Y0o(r.cwd,e)??"normal",v=Ge=>{A.setAutocompleteProvider(tIo(r.cwd,l,Ge))},C=()=>{A.mode=S==="normal"?"":S,A.modeStyle=X0o(S)};v(),C();let I=new xne(c,p,{cwd:r.cwd,palette:i,hideThinking:!0}),R=jcr(),N=t?"observer":"controller",F,j,V=0,q="",ae,be=Ge=>Ge>=1e3?`${(Ge/1e3).toFixed(Ge>=1e4?0:1)}k`:`${Ge}`,ye=()=>{let Ge=[];N!=="controller"&&Ge.push(i.muted("\u25CB read-only")),V===2?Ge.unshift(i.bashModeAlt("\u25CF bash \u2014 output hidden from agent")):V===1&&Ge.unshift(i.bashMode("\u25CF bash \u2014 output added to context")),j?.model&&Ge.push(i.info(j.model)),j?.isStreaming&&Ge.push(i.active("\u27F3")),ae!==void 0&&Ge.push(i.muted(`${be(ae)} ctx`)),j&&j.pendingMessageCount>0&&Ge.push(i.muted(`queued ${j.pendingMessageCount}`));let St=Ge.join(i.muted(" \xB7 ")),or=q?i.warning(`\u26A0 ${q}`):"",Mr=Math.max(1,(process.stdout.columns??80)-2),zr=xx(or),an=or?Math.max(0,Mr-zr-1):Mr,ot=xx(St)>an?Xn(St,an,"\u2026"):St,Mt=Math.max(or?1:0,Mr-xx(ot)-zr),Cr=or?ot+" ".repeat(Mt)+or:ot;E.clear(),E.addChild(new Dr(Cr,1,0)),c.requestRender()},ce=3e3,W,se=(Ge,St)=>{q=Ge,W!==void 0&&(clearTimeout(W),W=void 0),Ge&&!St?.sticky&&(W=setTimeout(()=>{W=void 0,q="",ye()},ce)),ye()},Te={},je=()=>{let{managers:Ge,reports:St}=xcr(e,Te,i);f.clear();for(let or of Ge)f.addChild(new Dr(or,1,0));m.clear();for(let or of St)m.addChild(new Dr(or,1,0));c.requestRender()},Ye=new vne(c,e,()=>Te,i),Le=()=>{je(),Ye.refresh()},oe=Ge=>{let St=[i.muted(Ge.dir)];Ge.branch&&St.push(i.info(`\u2387 ${Ge.branch}`));let or=[];Ge.dirty&&or.push(i.warning("\u25CF")),Ge.ahead>0&&or.push(i.active(`\u21E1${Ge.ahead}`)),Ge.behind>0&&or.push(i.active(`\u21E3${Ge.behind}`)),or.length>0&&St.push(or.join("")),A.info=` ${St.join(i.muted(" \xB7 "))} `,c.requestRender()},de=()=>Qcr(r.cwd,oe),ie=[],ee=()=>{if(h.clear(),ie.length>0){let Ge=Math.max(1,(process.stdout.columns??80)-4);h.addChild(new Dr(i.muted(`queued (${ie.length}):`),1,0));for(let St of ie){let or=St.replace(/\s+/g," ").trim();h.addChild(new Dr(i.faint(` \u22EF ${Xn(or,Ge,"\u2026")}`),1,0))}}c.requestRender()},ne=new Ine(c,A,a,{onCommand:Ge=>n.send(Ge),onDialogResponse:Ge=>n.send(Ge),onNotice:Ge=>se(Ge),nodeId:e,onGraph:()=>Ye.toggle(),onQuit:()=>rt("detach"),onCopy:()=>H(),onToggleToolsExpand:()=>se(I.toggleToolsExpanded()?"Tool output: expanded":"Tool output: collapsed"),onToggleThinking:()=>se(I.toggleThinking()?"Thinking blocks: hidden":"Thinking blocks: visible"),onRequest:Ge=>n.request(Ge),onMountPicker:Ge=>{g.addChild(Ge),c.requestRender()},onUnmountPicker:()=>{g.clear(),c.requestRender()},openLoginPicker:()=>{let Ge=F??oL();ne.openAuthPicker(St=>Bcr(c,Ge,()=>n.send({type:"reload_auth"}),or=>se(or),St))},openLogoutPicker:()=>{let Ge=F??oL();ne.openAuthPicker(St=>Fcr(Ge,()=>n.send({type:"reload_auth"}),or=>se(or),St.close))}}),H=()=>{let Ge=I.getLastAssistantText();if(!Ge){se("Nothing to copy \u2014 no assistant message yet");return}se(MUe(Ge)?"Copied last message to clipboard":"Copy failed \u2014 no clipboard tool found")},te=()=>{let Ge=eIo(V,j?.thinkingLevel,i);A.borderColor=Ge.borderColor,A.titleStyle=Ge.titleStyle};A.onChange=Ge=>{let St=Z0o(Ge);St!==V&&(V=St,te(),ye(),c.requestRender())};let me=Ge=>{j=Ge,ne.setState(Ge),A.title=Ge.sessionName?Ge.sessionName:"",te(),ye(),Le()},Ie=Ge=>{j!==void 0&&me({...j,...Ge})},ve=Ge=>{switch(Ge.type){case"welcome":{N=Ge.role,F=Ge.agentDir,I.applySnapshot(Ge.snapshot);let St=Ge.snapshot.stats.contextUsage;St&&St.tokens!=null&&(ae=St.tokens),me(Ge.snapshot.state),n.send({type:"get_commands"}),Ge.pending_dialog!=null&&ne.attachDialog(Ge.pending_dialog);break}case"control_changed":{N=Ge.controller_id===R?"controller":"observer",ye();break}case"model_changed":{Ie({model:Ge.model});break}case"error":{Ge.code==="not_controller"?se("read-only \u2014 another viewer is the controller"):se(`error: ${Ge.message}`);break}case"ack":{if(Ge.for==="get_commands"&&Ge.ok&&Ge.detail){try{let St=JSON.parse(Ge.detail);v(St)}catch{}break}if(Ge.for==="navigate_tree"&&Ge.ok){Ge.detail&&(A.setText(Ge.detail),c.requestRender());break}if(Ge.for==="share"){Ge.ok&&Ge.detail?(se(`Shared: ${Ge.detail}`),MUe(Ge.detail)):Ge.ok||se(`share failed: ${Ge.detail??"unknown error"}`);break}if(Ge.for==="clone"&&!Ge.ok){se(`clone failed: ${Ge.detail??"unknown error"}`);break}if(!Ge.ok){se(`command failed: ${Ge.for}${Ge.detail?` \u2014 ${Ge.detail}`:""}`);break}Ge.for==="reload"&&se("Reloaded keybindings, extensions, skills, prompts, and themes");break}case"extension_ui_request":{ne.attachDialog(Ge);break}case"bash_start":I.bashStart(Ge.command,Ge.excludeFromContext);break;case"bash_output":I.bashOutput(Ge.chunk);break;case"bash_end":I.bashEnd(Ge);break;default:{let St=Ge;if(I.handleEvent(St),St.type==="message_end"&&St.message.role==="assistant"){let or=St.message.usage;ae=or.input+or.cacheRead+or.cacheWrite,ye()}St.type==="agent_start"?Ie({isStreaming:!0}):St.type==="agent_end"?Ie({isStreaming:!1}):St.type==="session_info_changed"?Ie({sessionName:St.name}):St.type==="thinking_level_changed"?(Ie({thinkingLevel:St.level}),se(`Thinking level: ${St.level}`)):St.type==="queue_update"&&(ie=[...St.steering,...St.followUp],ee(),Ie({pendingMessageCount:ie.length}));break}}},he=!1,Se=()=>{if(!he){he=!0;try{c.stop()}catch{}}};process.once("exit",Se);let Re=!1,Ce,ke,xe=new Promise(Ge=>{ke=Ge}),J,rt=Ge=>{if(!Re){Re=!0,J!==void 0&&clearInterval(J),W!==void 0&&clearTimeout(W),Ye.close(),s(),Ar(),Ge==="detach"&&n.send({type:"bye"});try{I.dispose()}catch{}if(n.close(),Se(),Ge==="detach")try{aUe(e)}catch{}if(Ge==="broker-gone"){let St=Ce??`broker gone \u2014 node ${e} is no longer running its engine. Re-focus to reattach.`;process.stderr.write(`
|
|
1750
1750
|
${St}
|
|
1751
1751
|
`)}ke()}},st=()=>{n.send({type:"hello",role:N,client_id:R,term:{cols:process.stdout.columns??80,rows:process.stdout.rows??24}})},Lt=1e3,kt=3e3,dt=Ge=>new Promise(St=>setTimeout(St,Ge)),Et=!1,er=async()=>{Et=!0,se("\u21BB node refreshing \u2014 reconnecting\u2026",{sticky:!0});let Ge=200;try{for(;!Re;){let St;try{St=BQ(e)}catch{St=void 0}if(St!==void 0&&Vcr(St))break;let or=St?.status==="idle"||St?.intent==="idle-release";se(or?"\u25CC node dormant \u2014 the viewer reconnects on its next wake \xB7 ctrl+c to detach":"\u21BB node refreshing \u2014 reconnecting\u2026",{sticky:!0});try{await n.redial(),st(),se("");return}catch{await dt(or?kt:Math.min(Ge,Lt)),Ge*=2}}}finally{Et=!1}Re||rt("broker-gone")};n.on("frame",ve),n.on("error",Ge=>{Ce=Ge.message}),n.on("close",()=>{Re||Et||er()});let Or=1e3,hr=0,Ar=c.addInputListener(Ge=>{if(yi(Ge,"ctrl+d"))return rt("detach"),{consume:!0};if(yi(Ge,"ctrl+c")){let St=Date.now();return St-hr<Or?(hr=0,rt("detach"),{consume:!0}):(hr=St,A.setText(""),se("Editor cleared \u2014 press Ctrl+C again to detach"),c.requestRender(),{consume:!0})}if(yi(Ge,"alt+m")){let St=J0o(S);return K0o(r.cwd,e,St),S=St,C(),c.requestRender(),{consume:!0}}if(yi(Ge,"alt+g"))return Ye.toggle(),{consume:!0}}),ur=()=>rt("signal");process.once("SIGTERM",ur),process.once("SIGHUP",ur),process.once("SIGINT",ur),c.addChild(p),c.addChild(d),c.addChild(f),c.addChild(h),c.addChild(A),c.addChild(g),c.addChild(m),c.addChild(E),c.setFocus(A),ye(),je(),ee(),de(),c.start(),Rcr(u.kittyProtocolActive),st();let ht=5e3,Ve=()=>{Le(),de(),W0o(Qv(e),Ge=>{JSON.stringify(Ge)!==JSON.stringify(Te)&&(Te=Ge,Le())})};Ve(),J=setInterval(Ve,ht),typeof J.unref=="function"&&J.unref(),await xe}var nIo=gor({name:"to",description:"attach an interactive terminal viewer to a node's running broker",whenToUse:"you want to WATCH or DRIVE a headless node live in this pane \u2014 it connects over the node's unix socket to the broker that is already hosting the engine and renders the same chat stream, letting you type prompts (as the controller) or follow read-only (as an observer). It does NOT start the engine: the node must already have a running headless broker (focus or revive it first). One controller drives; extra viewers are read-only. ctrl+c / ctrl+d detaches and leaves the engine running",help:{name:"attach to",summary:"attach a terminal viewer to a headless node's running broker (controller by default, --observer for read-only); detach with ctrl+c/ctrl+d, the engine runs on",params:[{kind:"positional",name:"node",required:!0,constraint:"Node id to attach to. Must already have a running headless broker."},{kind:"flag",name:"observer",type:"bool",required:!1,default:!1,constraint:"Attach READ-ONLY: never claim control even if it is free. Default: drive (claim control if available, else fall back to read-only)."}],output:[{name:"note",type:"string",required:!1,constraint:"Only set on the non-TTY/piped path (a static notice); the interactive path returns nothing."}],outputKind:"object",effects:["Takes over the current pane in raw mode and renders the node's live engine stream until you detach (ctrl+c/ctrl+d) or the broker exits.","As controller: sends prompts/steers/dialog answers to the engine over the socket. As observer: read-only.","NEVER spawns pi and NEVER writes the session \u2014 it holds only a socket to the existing broker.","Outside a TTY (piped): prints a short notice and exits 0 \u2014 attach is an interactive program, not a pipe stage."]},run:async e=>{let t=e.node,r=e.observer??!1;if(!process.stdout.isTTY)return{note:`crtr surface attach is an interactive terminal viewer \u2014 run it in a tmux pane (a TTY), not a pipe. Node: ${t}`};await rIo(t,r)},render:e=>e.note!==void 0?String(e.note):""});function Etc(){return _or({name:"attach",rootEntry:{concept:"a terminal viewer for a headless node \u2014 connect over its socket and drive (or watch) the live engine",desc:"attach a terminal viewer to a headless node",useWhen:"you want to open a headless node live in this pane \u2014 watch its engine stream and drive it by typing, without the node ever running pi in your window. The node must already have a running headless broker; attach connects to it over a unix socket and never starts the engine itself. Detach (ctrl+c/ctrl+d) and the engine keeps running."},help:{name:"attach",summary:"attach a terminal viewer to a headless node's running broker",model:'The branch opens a viewer in the current pane. A TTY is required; piped invocation prints a notice and exits. By default it claims control and drives the engine; --observer follows read-only. One controller plus any number of observers may watch a node. ctrl+c / ctrl+d detaches cleanly and the engine runs on. If the broker exits, the viewer holds the pane and auto-reconnects \u2014 through a yield/refresh relaunch, and across dormancy until the node wakes again \u2014 reporting "broker gone" and exiting only when the node is terminal (done/dead/canceled) or deleted. attach NEVER spawns pi or writes the session \u2014 it holds only a socket to the broker, which must already be running.'},children:[nIo]})}export{tIo as buildAttachAutocompleteProvider,Z0o as deriveBashMode,Etc as registerAttach,eIo as resolveEditorFrameStyles};
|
|
1752
1752
|
/*! Bundled license information:
|
|
@@ -181,6 +181,11 @@ export class ChatView {
|
|
|
181
181
|
for (const [id, component] of renderedPendingTools) {
|
|
182
182
|
this.pendingTools.set(id, component);
|
|
183
183
|
}
|
|
184
|
+
// If we attached mid-turn, the `agent_start` that spins up the working loader
|
|
185
|
+
// already fired before we connected — restore it from the snapshot's streaming
|
|
186
|
+
// state so an actively-working node shows the spinner right away.
|
|
187
|
+
if (snapshot.state.isStreaming)
|
|
188
|
+
this.setActivity(this.makeLoader('Working...'));
|
|
184
189
|
this.tui.requestRender();
|
|
185
190
|
}
|
|
186
191
|
/** Stop any spinning activity loader. A one-shot `crtr surface attach` process lets its
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { PushDiffEngine } from '../push-engine.js';
|
|
7
|
+
import { PushLastSeenStore, PushRegistryStore } from '../push-registry.js';
|
|
8
|
+
function tmpDir() {
|
|
9
|
+
return mkdtempSync(join(tmpdir(), 'crtr-push-engine-'));
|
|
10
|
+
}
|
|
11
|
+
function makeStores(dir) {
|
|
12
|
+
return {
|
|
13
|
+
registry: new PushRegistryStore(join(dir, 'registry.json')),
|
|
14
|
+
lastSeen: new PushLastSeenStore(join(dir, 'last-seen.json')),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
// A real, allowlisted vendor push-service endpoint shape (Chromium/FCM) —
|
|
18
|
+
// endpoints must resolve to one of the small set of known browser-vendor push
|
|
19
|
+
// services (see push-registry.ts's PUSH_SERVICE_HOST_ALLOWLIST).
|
|
20
|
+
function fakeSubscription(id, authScope = 'local') {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
endpoint: `https://fcm.googleapis.com/fcm/send/${id}`,
|
|
24
|
+
keys: { p256dh: 'p'.repeat(32), auth: 'a'.repeat(16) },
|
|
25
|
+
authScope,
|
|
26
|
+
createdAt: 0,
|
|
27
|
+
lastSeenAt: 0,
|
|
28
|
+
prefs: { doneNotifications: true },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Seed a registry with one subscription directly (bypassing HTTP validation,
|
|
32
|
+
* which is covered in push-registry.test.ts). */
|
|
33
|
+
function seedRegistryWithOneSubscription(registry) {
|
|
34
|
+
registry.upsert(fakeSubscription('sub-1'));
|
|
35
|
+
}
|
|
36
|
+
function buildHarness(dir, opts = {}) {
|
|
37
|
+
const { registry, lastSeen } = makeStores(dir);
|
|
38
|
+
const sent = [];
|
|
39
|
+
const sentTo = [];
|
|
40
|
+
const nodes = [];
|
|
41
|
+
const asks = [];
|
|
42
|
+
const clock = { now: 1_000_000 };
|
|
43
|
+
const defaultSend = async (record, payload) => {
|
|
44
|
+
sent.push(payload);
|
|
45
|
+
sentTo.push(record.endpoint);
|
|
46
|
+
return 'sent';
|
|
47
|
+
};
|
|
48
|
+
const engine = new PushDiffEngine({
|
|
49
|
+
registry,
|
|
50
|
+
lastSeen,
|
|
51
|
+
readCanvasNodes: () => nodes,
|
|
52
|
+
readPendingAsks: () => asks,
|
|
53
|
+
sendPush: opts.sendPush ?? defaultSend,
|
|
54
|
+
isTargetScope: opts.isTargetScope ?? (() => true),
|
|
55
|
+
now: () => clock.now,
|
|
56
|
+
rateWindowMs: opts.rateWindowMs,
|
|
57
|
+
});
|
|
58
|
+
return { engine, sent, sentTo, registry, lastSeen, nodes, asks, clock };
|
|
59
|
+
}
|
|
60
|
+
test('seed() sends nothing regardless of current state (no restart storm)', async () => {
|
|
61
|
+
const dir = tmpDir();
|
|
62
|
+
try {
|
|
63
|
+
const h = buildHarness(dir);
|
|
64
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
65
|
+
h.nodes.push({ node_id: 'root-1', parent: null, status: 'done', attention_count: 0 });
|
|
66
|
+
h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
|
|
67
|
+
await h.engine.seed();
|
|
68
|
+
assert.equal(h.sent.length, 0);
|
|
69
|
+
const snap = h.lastSeen.snapshot();
|
|
70
|
+
assert.deepEqual(snap.roots['root-1'], { status: 'done', attention_count: 0 });
|
|
71
|
+
assert.deepEqual(snap.asks['ask-1'], { present: true });
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
test('active -> done emits exactly one done push, and a repeat pass does not duplicate', async () => {
|
|
78
|
+
const dir = tmpDir();
|
|
79
|
+
try {
|
|
80
|
+
const h = buildHarness(dir);
|
|
81
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
82
|
+
h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
|
|
83
|
+
await h.engine.seed();
|
|
84
|
+
assert.equal(h.sent.length, 0);
|
|
85
|
+
h.nodes[0].status = 'done';
|
|
86
|
+
await h.engine.onNodesInvalidation();
|
|
87
|
+
assert.equal(h.sent.length, 1);
|
|
88
|
+
assert.equal(h.sent[0].kind, 'done');
|
|
89
|
+
assert.equal(h.sent[0].conversationId, 'root-1');
|
|
90
|
+
// Repeated invalidation with the same (already-done) status must not re-fire.
|
|
91
|
+
await h.engine.onNodesInvalidation();
|
|
92
|
+
assert.equal(h.sent.length, 1);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
test('a new ask emits exactly one needs-you push, and repeated invalidations do not duplicate it', async () => {
|
|
99
|
+
const dir = tmpDir();
|
|
100
|
+
try {
|
|
101
|
+
const h = buildHarness(dir);
|
|
102
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
103
|
+
await h.engine.seed(); // no asks yet
|
|
104
|
+
h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
|
|
105
|
+
await h.engine.onInboxInvalidation();
|
|
106
|
+
assert.equal(h.sent.length, 1);
|
|
107
|
+
assert.equal(h.sent[0].kind, 'needs-you');
|
|
108
|
+
assert.equal(h.sent[0].askId, 'job-1');
|
|
109
|
+
// Same ask still pending on a second invalidation — no duplicate.
|
|
110
|
+
await h.engine.onInboxInvalidation();
|
|
111
|
+
assert.equal(h.sent.length, 1);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
rmSync(dir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
test('a resolved/missing ask never notifies', async () => {
|
|
118
|
+
const dir = tmpDir();
|
|
119
|
+
try {
|
|
120
|
+
const h = buildHarness(dir);
|
|
121
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
122
|
+
await h.engine.seed();
|
|
123
|
+
h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
|
|
124
|
+
await h.engine.onInboxInvalidation();
|
|
125
|
+
assert.equal(h.sent.length, 1);
|
|
126
|
+
// The ask resolves — it disappears from the pending list.
|
|
127
|
+
h.asks.length = 0;
|
|
128
|
+
await h.engine.onInboxInvalidation();
|
|
129
|
+
assert.equal(h.sent.length, 1); // no new push
|
|
130
|
+
assert.deepEqual(h.lastSeen.snapshot().asks, {});
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
test('multiple new asks in the same conversation within the rate window coalesce into one push', async () => {
|
|
137
|
+
const dir = tmpDir();
|
|
138
|
+
try {
|
|
139
|
+
const h = buildHarness(dir, { rateWindowMs: 30_000 });
|
|
140
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
141
|
+
await h.engine.seed();
|
|
142
|
+
h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' }, { id: 'ask-2', jobId: 'job-2', title: 'Approve deploy', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:01.000Z' });
|
|
143
|
+
await h.engine.onInboxInvalidation();
|
|
144
|
+
assert.equal(h.sent.length, 1);
|
|
145
|
+
assert.match(h.sent[0].snippet, /\+1 more/);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
test('push targeting is scope-filtered: only subscriptions on the watched auth boundary are sent to', async () => {
|
|
152
|
+
const dir = tmpDir();
|
|
153
|
+
try {
|
|
154
|
+
const h = buildHarness(dir, { isTargetScope: (s) => s === 'token:abc' });
|
|
155
|
+
// One local-scope sub and one token-scope sub in the SAME registry.
|
|
156
|
+
h.registry.upsert(fakeSubscription('sub-local', 'local'));
|
|
157
|
+
h.registry.upsert(fakeSubscription('sub-token', 'token:abc'));
|
|
158
|
+
h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
|
|
159
|
+
await h.engine.seed();
|
|
160
|
+
h.nodes[0].status = 'done';
|
|
161
|
+
await h.engine.onNodesInvalidation();
|
|
162
|
+
// Exactly one send, and it went to the token-scoped endpoint only.
|
|
163
|
+
assert.equal(h.sent.length, 1);
|
|
164
|
+
assert.deepEqual(h.sentTo, ['https://fcm.googleapis.com/fcm/send/sub-token']);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
rmSync(dir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
test('a transient send failure does not advance last-seen and is retried next pass', async () => {
|
|
171
|
+
const dir = tmpDir();
|
|
172
|
+
try {
|
|
173
|
+
let call = 0;
|
|
174
|
+
const attempts = [];
|
|
175
|
+
const h = buildHarness(dir, {
|
|
176
|
+
rateWindowMs: 30_000,
|
|
177
|
+
sendPush: async (_r, payload) => {
|
|
178
|
+
attempts.push(payload);
|
|
179
|
+
call += 1;
|
|
180
|
+
return call === 1 ? 'error' : 'sent';
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
184
|
+
h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
|
|
185
|
+
await h.engine.seed();
|
|
186
|
+
h.nodes[0].status = 'done';
|
|
187
|
+
await h.engine.onNodesInvalidation();
|
|
188
|
+
// The send was attempted but failed transiently — last-seen root must stay
|
|
189
|
+
// pre-transition ('active') so the edge is re-detected next pass.
|
|
190
|
+
assert.equal(attempts.length, 1);
|
|
191
|
+
assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'active');
|
|
192
|
+
// Advance past the rate window (the failed pass set lastPushAt) and retry.
|
|
193
|
+
h.clock.now += 60_000;
|
|
194
|
+
await h.engine.onNodesInvalidation();
|
|
195
|
+
assert.equal(attempts.length, 2);
|
|
196
|
+
assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'done');
|
|
197
|
+
// A further pass does not re-fire.
|
|
198
|
+
h.clock.now += 60_000;
|
|
199
|
+
await h.engine.onNodesInvalidation();
|
|
200
|
+
assert.equal(attempts.length, 2);
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
rmSync(dir, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
test('a transient failure on one subscription does not cause a retry to re-send to an already-delivered subscription', async () => {
|
|
207
|
+
const dir = tmpDir();
|
|
208
|
+
try {
|
|
209
|
+
// Keyed by endpoint, not `record.id` — upsert() derives the persisted id
|
|
210
|
+
// from `subscriptionIdForEndpoint(endpoint)`, discarding the fixture's
|
|
211
|
+
// caller-supplied id, so the endpoint is the stable fixture identity here.
|
|
212
|
+
const attemptsBySub = new Map();
|
|
213
|
+
const h = buildHarness(dir, {
|
|
214
|
+
rateWindowMs: 30_000,
|
|
215
|
+
sendPush: async (record, _payload) => {
|
|
216
|
+
const n = (attemptsBySub.get(record.endpoint) ?? 0) + 1;
|
|
217
|
+
attemptsBySub.set(record.endpoint, n);
|
|
218
|
+
// sub-ok always succeeds; sub-fail fails transiently on its first
|
|
219
|
+
// attempt only, then succeeds on retry — so the second pass proves
|
|
220
|
+
// sub-ok is never touched again while sub-fail finally lands.
|
|
221
|
+
return record.endpoint.includes('sub-fail') && n === 1 ? 'error' : 'sent';
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
h.registry.upsert(fakeSubscription('sub-ok'));
|
|
225
|
+
h.registry.upsert(fakeSubscription('sub-fail'));
|
|
226
|
+
h.nodes.push({ node_id: 'root-1', parent: null, status: 'active', attention_count: 0 });
|
|
227
|
+
await h.engine.seed();
|
|
228
|
+
h.nodes[0].status = 'done';
|
|
229
|
+
await h.engine.onNodesInvalidation();
|
|
230
|
+
// Both subscriptions were attempted once; the event is not fully delivered
|
|
231
|
+
// (sub-fail errored), so last-seen must stay pre-transition.
|
|
232
|
+
assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-ok'), 1);
|
|
233
|
+
assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-fail'), 1);
|
|
234
|
+
assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'active');
|
|
235
|
+
// Retry next pass (past the rate window): sub-ok must NOT be re-sent to —
|
|
236
|
+
// only sub-fail, which never succeeded, is retried.
|
|
237
|
+
h.clock.now += 60_000;
|
|
238
|
+
await h.engine.onNodesInvalidation();
|
|
239
|
+
assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-ok'), 1, 'an already-delivered subscription must not be re-sent to on retry');
|
|
240
|
+
assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-fail'), 2);
|
|
241
|
+
assert.equal(h.lastSeen.snapshot().roots['root-1'].status, 'done');
|
|
242
|
+
// Fully delivered now — a further pass does not re-fire for anyone.
|
|
243
|
+
h.clock.now += 60_000;
|
|
244
|
+
await h.engine.onNodesInvalidation();
|
|
245
|
+
assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-ok'), 1);
|
|
246
|
+
assert.equal(attemptsBySub.get('https://fcm.googleapis.com/fcm/send/sub-fail'), 2);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
test('overlapping inbox invalidations serialize — each distinct ask notifies exactly once', async () => {
|
|
253
|
+
const dir = tmpDir();
|
|
254
|
+
try {
|
|
255
|
+
const h = buildHarness(dir);
|
|
256
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
257
|
+
await h.engine.seed();
|
|
258
|
+
// First invalidation kicked off but NOT awaited; a second ask + invalidation
|
|
259
|
+
// land while the first pass is in-flight. The drain queue must serialize
|
|
260
|
+
// them so neither ask is notified twice.
|
|
261
|
+
h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
|
|
262
|
+
const p1 = h.engine.onInboxInvalidation();
|
|
263
|
+
h.asks.push({ id: 'ask-2', jobId: 'job-2', title: 'Approve deploy', conversationId: 'root-2', conversationTitle: 'Root Two', blockedSince: '2026-01-01T00:00:01.000Z' });
|
|
264
|
+
const p2 = h.engine.onInboxInvalidation();
|
|
265
|
+
await Promise.all([p1, p2]);
|
|
266
|
+
const askIds = h.sent.map((p) => p.askId).sort();
|
|
267
|
+
assert.deepEqual(askIds, ['job-1', 'job-2']);
|
|
268
|
+
assert.deepEqual(Object.keys(h.lastSeen.snapshot().asks).sort(), ['ask-1', 'ask-2']);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
rmSync(dir, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
test('a rate-capped ask is suppressed within the window then retried once it clears — never lost, never duplicated', async () => {
|
|
275
|
+
const dir = tmpDir();
|
|
276
|
+
try {
|
|
277
|
+
const h = buildHarness(dir, { rateWindowMs: 30_000 });
|
|
278
|
+
seedRegistryWithOneSubscription(h.registry);
|
|
279
|
+
await h.engine.seed();
|
|
280
|
+
h.asks.push({ id: 'ask-1', jobId: 'job-1', title: 'Pick a color', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:00.000Z' });
|
|
281
|
+
await h.engine.onInboxInvalidation();
|
|
282
|
+
assert.equal(h.sent.length, 1); // first ask fires immediately
|
|
283
|
+
// A second, distinct ask lands in the same conversation moments later —
|
|
284
|
+
// still inside the rate window, so it must be suppressed, not sent.
|
|
285
|
+
h.asks.push({ id: 'ask-2', jobId: 'job-2', title: 'Approve deploy', conversationId: 'root-1', conversationTitle: 'Root One', blockedSince: '2026-01-01T00:00:05.000Z' });
|
|
286
|
+
h.clock.now += 5_000;
|
|
287
|
+
await h.engine.onInboxInvalidation();
|
|
288
|
+
assert.equal(h.sent.length, 1); // still suppressed
|
|
289
|
+
assert.equal(h.lastSeen.snapshot().asks['ask-2'], undefined); // left untracked so it retries
|
|
290
|
+
// Once the rate window clears, the still-pending ask-2 is retried exactly once.
|
|
291
|
+
h.clock.now += 30_000;
|
|
292
|
+
await h.engine.onInboxInvalidation();
|
|
293
|
+
assert.equal(h.sent.length, 2);
|
|
294
|
+
assert.equal(h.sent[1].askId, 'job-2');
|
|
295
|
+
// And it never fires a third time on a further no-op pass.
|
|
296
|
+
await h.engine.onInboxInvalidation();
|
|
297
|
+
assert.equal(h.sent.length, 2);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
rmSync(dir, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|