@north-light/crouter 0.3.189 → 0.3.191
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/api/dto/broker-ops.d.ts +6 -1
- package/dist/api/dto/broker.d.ts +25 -3
- package/dist/clients/attach/session/frames.js +6 -0
- package/dist/clients/attach/viewer.js +1 -1
- package/dist/core/__tests__/extension-abort.test.d.ts +1 -0
- package/dist/core/__tests__/extension-abort.test.js +18 -0
- package/dist/core/__tests__/serial/worktree.test.js +83 -0
- package/dist/core/runtime/broker/extension-abort.d.ts +9 -0
- package/dist/core/runtime/broker/extension-abort.js +23 -0
- package/dist/core/runtime/broker/node-named.d.ts +14 -0
- package/dist/core/runtime/broker/node-named.js +27 -0
- package/dist/core/runtime/broker/rebind.js +7 -1
- package/dist/core/runtime/broker-protocol.d.ts +11 -2
- package/dist/core/runtime/broker.js +6 -0
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/worktree.d.ts +4 -0
- package/dist/core/worktree.js +137 -9
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +57 -0
- package/dist/daemon/api/handlers/broker-ops.js +12 -2
- package/dist/daemon/api/handlers/reports.js +8 -3
- package/dist/pi-extensions/broker-local.js +15 -2
- package/dist/pi-extensions/canvas-stophook.js +8 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/runtime/naming-persist.d.ts +0 -16
- package/dist/core/runtime/naming-persist.js +0 -68
|
@@ -141,10 +141,15 @@ export type BrokerGeneratedNameRequest = {
|
|
|
141
141
|
kind: string;
|
|
142
142
|
};
|
|
143
143
|
};
|
|
144
|
-
/** A daemon-selected Pi editor-label directive. No handler calls a broker.
|
|
144
|
+
/** A daemon-selected Pi editor-label directive. No handler calls a broker.
|
|
145
|
+
* `description`/`icon` are the values as STORED (an empty icon is left unset),
|
|
146
|
+
* so the broker announces what the node now carries rather than what it asked
|
|
147
|
+
* for. All three are present exactly when `applied`. */
|
|
145
148
|
export interface BrokerGeneratedNameResultDTO {
|
|
146
149
|
applied: boolean;
|
|
147
150
|
editorLabel?: string;
|
|
151
|
+
description?: string;
|
|
152
|
+
icon?: string;
|
|
148
153
|
}
|
|
149
154
|
export interface BrokerPersonaAckRequest {
|
|
150
155
|
from: {
|
package/dist/api/dto/broker.d.ts
CHANGED
|
@@ -30,12 +30,34 @@ export interface BrokerWelcomeFrame<M = unknown> {
|
|
|
30
30
|
snapshot?: BrokerWelcomeSnapshot<M>;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* The broker
|
|
34
|
-
*
|
|
33
|
+
* The crtrd broker `node_named` control frame — the node's generated name at the
|
|
34
|
+
* instant crtrd committed it.
|
|
35
|
+
*
|
|
36
|
+
* A node names itself off its first real message: the naming extension asks a
|
|
37
|
+
* headless model for a handle, commits it through crtrd's guarded compare-and-set,
|
|
38
|
+
* and pushes this frame down the attach stream in the same callback. It is sent
|
|
39
|
+
* ONLY when that guarded commit APPLIED, so a name a human already set is never
|
|
40
|
+
* announced as generated.
|
|
41
|
+
*
|
|
42
|
+
* It exists because the name lands SECONDS AFTER the turn that triggered it — a
|
|
43
|
+
* consumer that settles its own display state on turn-end reads an unnamed node and
|
|
44
|
+
* has nothing left to re-read on. This frame is the push: `description` is the
|
|
45
|
+
* 3-8 word kebab-case handle, `icon` the Nerd Font glyph the namer chose (empty
|
|
46
|
+
* when it chose none), and `editorLabel` crtrd's own rendered label for the node.
|
|
47
|
+
*/
|
|
48
|
+
export interface BrokerNodeNamedFrame {
|
|
49
|
+
type: 'node_named';
|
|
50
|
+
description: string;
|
|
51
|
+
icon: string;
|
|
52
|
+
editorLabel: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The broker-control frames a relay consumer reads: `welcome` (catch-up snapshot),
|
|
56
|
+
* `error`, and `node_named`. The broker interleaves others (display_*, ack, …)
|
|
35
57
|
* under non-colliding `type` discriminants; a relay mapper drops those through its
|
|
36
58
|
* `default` arm untyped, so they are not enumerated here.
|
|
37
59
|
*/
|
|
38
|
-
export type BrokerControlFrame<M = unknown> = BrokerWelcomeFrame<M> | BrokerErrorFrame;
|
|
60
|
+
export type BrokerControlFrame<M = unknown> = BrokerWelcomeFrame<M> | BrokerErrorFrame | BrokerNodeNamedFrame;
|
|
39
61
|
/**
|
|
40
62
|
* What the crtrd broker attach delivers to a relay consumer: the live engine
|
|
41
63
|
* event stream (`E` — pi's `AgentSessionEvent`, relayed verbatim) unioned with the
|
|
@@ -151,6 +151,12 @@ export function createFrameHandler(ctx, fx) {
|
|
|
151
151
|
case 'error':
|
|
152
152
|
fx.setNotice(frame.code === 'read_only' ? 'read-only' : `error: ${frame.message}`);
|
|
153
153
|
break;
|
|
154
|
+
case 'node_named':
|
|
155
|
+
// Nothing to do here: the naming extension shares this process, so it
|
|
156
|
+
// set the live session name in the same callback and pi's own
|
|
157
|
+
// `session_info_changed` already moved this viewer's label chip. The
|
|
158
|
+
// frame exists for clients that are NOT in the broker's process.
|
|
159
|
+
break;
|
|
154
160
|
case 'ack':
|
|
155
161
|
handleAck(frame);
|
|
156
162
|
break;
|
|
@@ -1534,7 +1534,7 @@ ${m}
|
|
|
1534
1534
|
Follow the instructions below exactly as written \u2014 every part, including the presence or absence of arguments above, is a load-bearing requirement.
|
|
1535
1535
|
|
|
1536
1536
|
${n}`}function Gfr(e){try{let t=JSON.parse(e);return Array.isArray(t)?t.flatMap(r=>{if(typeof r!="object"||r===null||Array.isArray(r))return[];let n=r;return typeof n.name!="string"||typeof n.description!="string"||typeof n.source!="string"?[]:n.source!=="builtin"&&n.source!=="command"&&n.source!=="template"?[]:n.template!==void 0&&typeof n.template!="string"?[]:n.expansion!==void 0&&!ise(n.expansion)?[]:[{name:n.name,description:n.description,source:n.source,...typeof n.template=="string"?{template:n.template}:{},...ise(n.expansion)?{expansion:n.expansion}:{}}]}):[]}catch{return[]}}function U9n(e){let t=[],r="",n=null;for(let o of e)n!==null?o===n?n=null:r+=o:o==='"'||o==="'"?n=o:/\s/.test(o)?r!==""&&(t.push(r),r=""):r+=o;return r!==""&&t.push(r),t}function F9n(e,t){let r=t.join(" ");return e.replace(/\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g,(n,o,i,s,a,l)=>{if(o!==void 0)return t[Number.parseInt(o,10)-1]||i||"";if(s!==void 0){let u=Math.max(0,Number.parseInt(s,10)-1);return a!==void 0?t.slice(u,u+Number.parseInt(a,10)).join(" "):t.slice(u).join(" ")}return l==="ARGUMENTS"||l==="@"?r:t[Number.parseInt(l||"0",10)-1]??""})}function Qfr(e,t){let r=e.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);if(r===null)return;let n=r[1];if(t.some(l=>l.source==="builtin"&&l.name===n))return;let o=e.indexOf(" "),i=o===-1?e.slice(1):e.slice(1,o),s=t.find(l=>l.source==="command"&&l.name===i);if(s!==void 0){if(s.expansion===void 0||!ise(s.expansion))return;let l=o===-1?"":e.slice(o+1);return{name:s.name,invocation:e,content:zfr(s.expansion,l)}}let a=t.find(l=>l.source==="template"&&l.name===n);if(typeof a?.template=="string")return{name:a.name,invocation:e,content:F9n(a.template,U9n(r[2]??""))}}var B9n="CRTR_TIMING",qfr="CRTR_TIMING_T0";function $9n(){return performance.timeOrigin+performance.now()}var Vfr=(()=>{let e=process.env[B9n];return e!==void 0&&e!==""&&e!=="0"&&e.toLowerCase()!=="false"})(),z9n=(()=>{if(!Vfr)return 0;let e=Number(process.env[qfr]);if(Number.isFinite(e)&&e>0)return e;let t=performance.timeOrigin;return process.env[qfr]=String(t),t})(),G9n=(()=>{let e=process.env.CRTR_TIMING_PROC;return e!==void 0&&e!==""?e:process.argv[2]==="surface"&&process.argv[3]==="attach"?"viewer":"cli"})(),W$e=0;function Cw(e,t){if(!Vfr)return;let r=$9n(),n=r-z9n,o=W$e===0?n:r-W$e;W$e=r;let i=performance.now(),s=t===void 0?"":" "+Object.entries(t).map(([u,c])=>`${u}=${String(c)}`).join(" "),a=process.env.CRTR_TIMING_PROC??G9n,l=u=>u.toFixed(1).padStart(8);process.stderr.write(`[timing] +${l(n)}ms \u0394${l(o)}ms up=${l(i)}ms t=${r.toFixed(1)} ${a}/${process.pid} ${e}${s}
|
|
1537
|
-
`)}function Wfr(e,t){let{socket:r}=e,n=()=>{r.send({type:"get_commands"}),t.resetMemoryRefs()},o=a=>{if(a.for==="get_commands"&&a.ok&&a.detail){try{t.setCommands(Gfr(a.detail))}catch{}return}if(a.for==="navigate_tree"&&a.ok){a.detail&&t.setEditorText(a.detail);return}if(a.for==="share"){a.ok&&a.detail?(t.setNotice(`Shared: ${a.detail}`),b2e(a.detail)):a.ok||t.setNotice(`share failed: ${a.detail??"unknown error"}`);return}if(a.for==="clone"&&!a.ok){t.setNotice(`clone failed: ${a.detail??"unknown error"}`);return}if(!a.ok){t.setNotice(`command failed: ${a.for}${a.detail?` \u2014 ${a.detail}`:""}`);return}a.for==="reload"&&(t.reloadBindings(),t.setCommands(),n(),t.setNotice("Reloaded keybindings, extensions, skills, prompts, and themes"))},i=a=>{t.chat.handleEvent(a),a.type==="session_info_changed"?t.refreshNodeIdentity():a.type==="thinking_level_changed"?t.setNotice(t.modelThinkingLabel()??`Thinking level: ${a.level}`):(a.type==="tool_execution_start"||a.type==="tool_execution_end")&&t.renderFooter()},s=!1;return a=>{switch(t.foldState(a),a.type){case"welcome":{s||(s=!0,Cw("viewer.welcome")),t.chat.applySnapshot(a.snapshot),t.refreshNodeIdentity(),n(),a.pending_dialog!=null&&t.attachDialog(a.pending_dialog);break}case"tool_group_summary":t.chat.applyToolGroupSummary(a.key,a.summary);break;case"working_activity":t.chat.setWorkingActivity(a.activity);break;case"model_changed":{let l=t.modelThinkingLabel();l&&t.setNotice(l);break}case"error":t.setNotice(a.code==="read_only"?"read-only":`error: ${a.message}`);break;case"ack":o(a);break;case"extension_ui_request":a.method!=="setStatus"&&a.method!=="setWidget"&&a.method!=="setTitle"&&t.attachDialog(a);break;case"extension_ui_dismiss":t.dismissDialog(a.id);break;case"bash_start":t.chat.bashStart(a.command,a.excludeFromContext);break;case"bash_output":t.chat.bashOutput(a.chunk);break;case"bash_end":t.chat.bashEnd(a);break;default:i(a);break}}}var Q9n=["crtr.attach.scroll.up","crtr.attach.scroll.down","crtr.attach.scroll.page-up","crtr.attach.scroll.page-down","crtr.attach.scroll.live","crtr.attach.mouse.toggle","crtr.attach.copy.message"];function jfr(e,t,r){return lI(e,t,cI(r),(n,o)=>ln(n,o))}function j$e(e,t){t[e]()}function Hfr(e,t,r){for(let{actionId:n}of w_)if(jfr(e,n,t))return j$e(n,r),!0;return!1}function Yfr(e,t,r){for(let n of Q9n)if(jfr(e,n,t))return r[n](),!0;return!1}function Kfr(e){for(let t of["crtr.attach.clear-or-detach","crtr.attach.detach"])if(e.gestures(t).length>0)return Zh(e,t)}function H$e(e,t,r,n){let o=e();if(t(o.bindings),r())try{n(o.bindings,o.tmuxPassthrough)}catch{}return o}var q9n=1e3;function Jfr(e,t){let{tui:r,editor:n}=e,o=0,i={"crtr.attach.detach":t.detach,"crtr.attach.clear-or-detach":()=>{let s=Date.now();if(s-o<q9n){o=0,t.detach();return}o=s,n.setText(""),t.setNotice(`Editor cleared \u2014 press ${t.detachHint()} again to detach`),r.requestRender()},"crtr.attach.graph.toggle":t.toggleGraph,"crtr.attach.help.toggle":t.toggleHelp,"crtr.attach.inbox.toggle":t.toggleInbox,"crtr.attach.model-ladder.next":()=>t.cycleModelLadder("forward"),"crtr.attach.model-ladder.previous":()=>t.cycleModelLadder("backward"),"crtr.attach.command.inspect":t.inspectLoadedCommand,"crtr.attach.file-review":t.openFileReview,"crtr.attach.profile-files":t.openProfileFiles,"crtr.attach.whip":t.whip};return r.addInputListener(s=>{if(s.includes("\x1B[I")||s.includes("\x1B[O")){t.setPaneFocused(s.lastIndexOf("\x1B[I")>s.lastIndexOf("\x1B[O")),r.requestRender();let l=s.replaceAll("\x1B[I","").replaceAll("\x1B[O","");return l.length>0?{data:l}:{consume:!0}}let a=Ece(s);if(a!==void 0)return j$e(a.actionId,i),a.remaining.length>0?{data:a.remaining}:{consume:!0};if(!t.hasExclusiveSurface())return n.focused&&Yfr(t.bindings(),s,t.viewportActions())?{consume:!0}:t.rosterHandleKey(s)?{consume:!0}:Hfr(t.bindings(),s,i)?{consume:!0}:void 0})}import{spawnSync as V9n}from"node:child_process";var W9n="\x1B[?1002h\x1B[?1006h",Xfr="\x1B[?1002l\x1B[?1006l",Zfr=3;function j9n(){if(!Ll())return!1;let e=V9n("tmux",["show-options","-gv","mouse"],{encoding:"utf8"});return e.status===0&&e.stdout?.trim()==="on"}function ehr(e,t){let r=t==="on"||t==="tmux"&&j9n(),n=()=>{e(r?W9n:Xfr)},o=()=>{r=!1;try{e(Xfr)}catch{}};return process.once("exit",o),{enabled:()=>r,toggle:()=>(r=!r,n(),r),reassert:n,disable:o,install:(i,s)=>i.addInputListener(a=>{let l=!1,c=a.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g,(p,d,f,m,h)=>{l=!0;let g=Number(d),y=g&-29;return y===64?s.onWheel("up",Zfr,Number(f)):y===65?s.onWheel("down",Zfr,Number(f)):y===32&&h==="M"?s.onPrimaryDrag(Number(f),Number(m)):y===0&&h==="m"?s.onPrimaryRelease(Number(f),Number(m)):g===0&&h==="M"&&s.onPrimaryPress(Number(f),Number(m)),""}).replace(/\x1b\[M[\s\S]{3}/g,()=>(l=!0,""));if(l)return c.length>0?{data:c}:{consume:!0}})}}var H9n=new Intl.Segmenter(void 0,{granularity:"grapheme"}),J$e="\x1B[7m",thr="\x1B[27m";function Y9n(e,t){let r=e[t+1];if(r!==void 0){if(r==="]"||r==="_"||r==="P"||r==="^"){let n=e.indexOf("\x07",t+2),o=e.indexOf("\x1B\\",t+2),i=n===-1?o:o===-1?n:Math.min(n,o);return i===-1?void 0:{text:e.slice(t,i+(i===n?1:2)),end:i+(i===n?1:2)}}if(r==="["){let n=t+2;for(;n<e.length&&(e.charCodeAt(n)<64||e.charCodeAt(n)>126);)n++;return n>=e.length?void 0:{text:e.slice(t,n+1),end:n+1}}return{text:e.slice(t,t+2),end:t+2}}}function sse(e,t){let r=0,n=0;for(;n<e.length;){if(e[n]==="\x1B"){let s=Y9n(e,n);if(s===void 0)break;t.onEscape?.(s.text),n=s.end;continue}let o=e.indexOf("\x1B",n),i=e.slice(n,o===-1?void 0:o);for(let s of H9n.segment(i)){let a=$a(s.segment);t.onGlyph?.({text:s.segment,cell:r,width:a}),r+=a}n=o===-1?e.length:o}return r}function rhr(e,t,r){let n="";return sse(e,{onGlyph:o=>{o.cell>=t&&o.cell<r&&(n+=o.text)}}),n}function nhr(e,t,r,n){let o=Math.min(r,n);if(o<=t||t<0)return e;let i="",s=!1,a=()=>{s||(i+=J$e,s=!0)},l=()=>{s&&(i+=thr,s=!1)},u=sse(e,{onEscape:c=>{i+=c,s&&c.endsWith("m")&&(i+=J$e)},onGlyph:c=>{c.cell>=t&&c.cell<o?a():l(),i+=c.text}});if(l(),u<o){let c=Math.max(0,t-u),p=o-Math.max(t,u);i+=" ".repeat(c)+J$e+" ".repeat(p)+thr}return i}function K9n(e,t){return e.row<t.row||e.row===t.row&&e.column<=t.column}function ohr(e,t,r){let[n,o]=K9n(e,t)?[e,t]:[t,e],i=[];for(let s=n.row;s<=o.row;s++){let a=s===n.row?n.column-1:0,l=s===o.row?o.column:r;l>a&&i.push({row:s,from:a,to:l})}return i}function ihr(e){let t,r,n=()=>t!==void 0&&r!==void 0?{anchor:t,head:r}:void 0,o=()=>{let i=n();if(!i)return"";let s=e.frame(),a=ohr(i.anchor,i.head,e.width()).map(l=>rhr(s[l.row-1]??"",l.from,l.to).trimEnd());for(;a.length>0&&a[a.length-1]==="";)a.pop();return a.join(`
|
|
1537
|
+
`)}function Wfr(e,t){let{socket:r}=e,n=()=>{r.send({type:"get_commands"}),t.resetMemoryRefs()},o=a=>{if(a.for==="get_commands"&&a.ok&&a.detail){try{t.setCommands(Gfr(a.detail))}catch{}return}if(a.for==="navigate_tree"&&a.ok){a.detail&&t.setEditorText(a.detail);return}if(a.for==="share"){a.ok&&a.detail?(t.setNotice(`Shared: ${a.detail}`),b2e(a.detail)):a.ok||t.setNotice(`share failed: ${a.detail??"unknown error"}`);return}if(a.for==="clone"&&!a.ok){t.setNotice(`clone failed: ${a.detail??"unknown error"}`);return}if(!a.ok){t.setNotice(`command failed: ${a.for}${a.detail?` \u2014 ${a.detail}`:""}`);return}a.for==="reload"&&(t.reloadBindings(),t.setCommands(),n(),t.setNotice("Reloaded keybindings, extensions, skills, prompts, and themes"))},i=a=>{t.chat.handleEvent(a),a.type==="session_info_changed"?t.refreshNodeIdentity():a.type==="thinking_level_changed"?t.setNotice(t.modelThinkingLabel()??`Thinking level: ${a.level}`):(a.type==="tool_execution_start"||a.type==="tool_execution_end")&&t.renderFooter()},s=!1;return a=>{switch(t.foldState(a),a.type){case"welcome":{s||(s=!0,Cw("viewer.welcome")),t.chat.applySnapshot(a.snapshot),t.refreshNodeIdentity(),n(),a.pending_dialog!=null&&t.attachDialog(a.pending_dialog);break}case"tool_group_summary":t.chat.applyToolGroupSummary(a.key,a.summary);break;case"working_activity":t.chat.setWorkingActivity(a.activity);break;case"model_changed":{let l=t.modelThinkingLabel();l&&t.setNotice(l);break}case"error":t.setNotice(a.code==="read_only"?"read-only":`error: ${a.message}`);break;case"node_named":break;case"ack":o(a);break;case"extension_ui_request":a.method!=="setStatus"&&a.method!=="setWidget"&&a.method!=="setTitle"&&t.attachDialog(a);break;case"extension_ui_dismiss":t.dismissDialog(a.id);break;case"bash_start":t.chat.bashStart(a.command,a.excludeFromContext);break;case"bash_output":t.chat.bashOutput(a.chunk);break;case"bash_end":t.chat.bashEnd(a);break;default:i(a);break}}}var Q9n=["crtr.attach.scroll.up","crtr.attach.scroll.down","crtr.attach.scroll.page-up","crtr.attach.scroll.page-down","crtr.attach.scroll.live","crtr.attach.mouse.toggle","crtr.attach.copy.message"];function jfr(e,t,r){return lI(e,t,cI(r),(n,o)=>ln(n,o))}function j$e(e,t){t[e]()}function Hfr(e,t,r){for(let{actionId:n}of w_)if(jfr(e,n,t))return j$e(n,r),!0;return!1}function Yfr(e,t,r){for(let n of Q9n)if(jfr(e,n,t))return r[n](),!0;return!1}function Kfr(e){for(let t of["crtr.attach.clear-or-detach","crtr.attach.detach"])if(e.gestures(t).length>0)return Zh(e,t)}function H$e(e,t,r,n){let o=e();if(t(o.bindings),r())try{n(o.bindings,o.tmuxPassthrough)}catch{}return o}var q9n=1e3;function Jfr(e,t){let{tui:r,editor:n}=e,o=0,i={"crtr.attach.detach":t.detach,"crtr.attach.clear-or-detach":()=>{let s=Date.now();if(s-o<q9n){o=0,t.detach();return}o=s,n.setText(""),t.setNotice(`Editor cleared \u2014 press ${t.detachHint()} again to detach`),r.requestRender()},"crtr.attach.graph.toggle":t.toggleGraph,"crtr.attach.help.toggle":t.toggleHelp,"crtr.attach.inbox.toggle":t.toggleInbox,"crtr.attach.model-ladder.next":()=>t.cycleModelLadder("forward"),"crtr.attach.model-ladder.previous":()=>t.cycleModelLadder("backward"),"crtr.attach.command.inspect":t.inspectLoadedCommand,"crtr.attach.file-review":t.openFileReview,"crtr.attach.profile-files":t.openProfileFiles,"crtr.attach.whip":t.whip};return r.addInputListener(s=>{if(s.includes("\x1B[I")||s.includes("\x1B[O")){t.setPaneFocused(s.lastIndexOf("\x1B[I")>s.lastIndexOf("\x1B[O")),r.requestRender();let l=s.replaceAll("\x1B[I","").replaceAll("\x1B[O","");return l.length>0?{data:l}:{consume:!0}}let a=Ece(s);if(a!==void 0)return j$e(a.actionId,i),a.remaining.length>0?{data:a.remaining}:{consume:!0};if(!t.hasExclusiveSurface())return n.focused&&Yfr(t.bindings(),s,t.viewportActions())?{consume:!0}:t.rosterHandleKey(s)?{consume:!0}:Hfr(t.bindings(),s,i)?{consume:!0}:void 0})}import{spawnSync as V9n}from"node:child_process";var W9n="\x1B[?1002h\x1B[?1006h",Xfr="\x1B[?1002l\x1B[?1006l",Zfr=3;function j9n(){if(!Ll())return!1;let e=V9n("tmux",["show-options","-gv","mouse"],{encoding:"utf8"});return e.status===0&&e.stdout?.trim()==="on"}function ehr(e,t){let r=t==="on"||t==="tmux"&&j9n(),n=()=>{e(r?W9n:Xfr)},o=()=>{r=!1;try{e(Xfr)}catch{}};return process.once("exit",o),{enabled:()=>r,toggle:()=>(r=!r,n(),r),reassert:n,disable:o,install:(i,s)=>i.addInputListener(a=>{let l=!1,c=a.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g,(p,d,f,m,h)=>{l=!0;let g=Number(d),y=g&-29;return y===64?s.onWheel("up",Zfr,Number(f)):y===65?s.onWheel("down",Zfr,Number(f)):y===32&&h==="M"?s.onPrimaryDrag(Number(f),Number(m)):y===0&&h==="m"?s.onPrimaryRelease(Number(f),Number(m)):g===0&&h==="M"&&s.onPrimaryPress(Number(f),Number(m)),""}).replace(/\x1b\[M[\s\S]{3}/g,()=>(l=!0,""));if(l)return c.length>0?{data:c}:{consume:!0}})}}var H9n=new Intl.Segmenter(void 0,{granularity:"grapheme"}),J$e="\x1B[7m",thr="\x1B[27m";function Y9n(e,t){let r=e[t+1];if(r!==void 0){if(r==="]"||r==="_"||r==="P"||r==="^"){let n=e.indexOf("\x07",t+2),o=e.indexOf("\x1B\\",t+2),i=n===-1?o:o===-1?n:Math.min(n,o);return i===-1?void 0:{text:e.slice(t,i+(i===n?1:2)),end:i+(i===n?1:2)}}if(r==="["){let n=t+2;for(;n<e.length&&(e.charCodeAt(n)<64||e.charCodeAt(n)>126);)n++;return n>=e.length?void 0:{text:e.slice(t,n+1),end:n+1}}return{text:e.slice(t,t+2),end:t+2}}}function sse(e,t){let r=0,n=0;for(;n<e.length;){if(e[n]==="\x1B"){let s=Y9n(e,n);if(s===void 0)break;t.onEscape?.(s.text),n=s.end;continue}let o=e.indexOf("\x1B",n),i=e.slice(n,o===-1?void 0:o);for(let s of H9n.segment(i)){let a=$a(s.segment);t.onGlyph?.({text:s.segment,cell:r,width:a}),r+=a}n=o===-1?e.length:o}return r}function rhr(e,t,r){let n="";return sse(e,{onGlyph:o=>{o.cell>=t&&o.cell<r&&(n+=o.text)}}),n}function nhr(e,t,r,n){let o=Math.min(r,n);if(o<=t||t<0)return e;let i="",s=!1,a=()=>{s||(i+=J$e,s=!0)},l=()=>{s&&(i+=thr,s=!1)},u=sse(e,{onEscape:c=>{i+=c,s&&c.endsWith("m")&&(i+=J$e)},onGlyph:c=>{c.cell>=t&&c.cell<o?a():l(),i+=c.text}});if(l(),u<o){let c=Math.max(0,t-u),p=o-Math.max(t,u);i+=" ".repeat(c)+J$e+" ".repeat(p)+thr}return i}function K9n(e,t){return e.row<t.row||e.row===t.row&&e.column<=t.column}function ohr(e,t,r){let[n,o]=K9n(e,t)?[e,t]:[t,e],i=[];for(let s=n.row;s<=o.row;s++){let a=s===n.row?n.column-1:0,l=s===o.row?o.column:r;l>a&&i.push({row:s,from:a,to:l})}return i}function ihr(e){let t,r,n=()=>t!==void 0&&r!==void 0?{anchor:t,head:r}:void 0,o=()=>{let i=n();if(!i)return"";let s=e.frame(),a=ohr(i.anchor,i.head,e.width()).map(l=>rhr(s[l.row-1]??"",l.from,l.to).trimEnd());for(;a.length>0&&a[a.length-1]==="";)a.pop();return a.join(`
|
|
1538
1538
|
`)};return{begin(i,s){t={row:s,column:i},r=void 0},extend(i,s){return t===void 0||r!==void 0&&r.row===s&&r.column===i?!1:(r={row:s,column:i},!0)},release(){let i=t;if(i===void 0)return;if(r===void 0)return t=void 0,{kind:"click",column:i.column,row:i.row};let s=o();if(s==="")return t=void 0,r=void 0,{kind:"selection",notice:"Nothing to copy \u2014 selection is empty"};let{system:a,tmux:l}=Joe(s),u=a&&l?"clipboard + tmux buffer":a?"clipboard":l?"tmux buffer":void 0,c=s.split(`
|
|
1539
1539
|
`).length;return{kind:"selection",notice:u===void 0?"Copy failed \u2014 no clipboard tool found":`Copied selection (${c} ${c===1?"line":"lines"}) \u2014 ${u}`}},clear(){let i=r!==void 0;return t=void 0,r=void 0,i},active(){return r!==void 0},apply(i){let s=n();if(!s)return i.slice();let a=e.width(),l=i.slice();for(let u of ohr(s.anchor,s.head,a)){let c=u.row-1;c<0||c>=l.length||(l[c]=nhr(l[c],u.from,u.to,a))}return l}}}import{existsSync as J9n}from"node:fs";import{fileURLToPath as X9n}from"node:url";function shr(e,t,r){if(!Number.isInteger(t)||!Number.isInteger(r)||t<1||r<1)return;let n;for(let o=0;o<Math.min(t,e.length);o++){let i=o===t-1?r:void 0,s=Z9n(e[o]??"",n,i);if(n=s.activeUri,s.uri!==void 0)return s.uri}}function Z9n(e,t,r){let n=t,o;return sse(e,{onEscape:i=>{let s=e7n(i);s!==null&&(n=s===""?void 0:s)},onGlyph:i=>{if(o!==void 0||r===void 0||n===void 0||i.width===0)return;let s=r-1;s>=i.cell&&s<i.cell+i.width&&(o=n)}}),{activeUri:n,uri:o}}function e7n(e){if(!e.startsWith("\x1B]8;"))return null;let t=e.endsWith("\x1B\\")?2:1,r=e.slice(4,-t),n=r.indexOf(";");return n===-1?null:r.slice(n+1)}function ahr(e){let t;try{let o=new URL(e.uri);if(o.protocol!=="file:")return"invalid";t=X9n(o)}catch{return"invalid"}if(!J9n(t))return"missing";let r=`crtr surface attach to ${ps(e.nodeId)}${e.observer?" --observer":""}`,n=`nvim ${ps(t)} || :; exec ${r}`;return e.beforeRespawn(),Rce({pane:e.pane,cwd:e.cwd,env:kce(),command:n})?"opened":"failed"}import{execFile as t7n}from"node:child_process";function chr(e){switch(e){case"active":return 0;case"idle":return 1;case"done":return 2;case"canceled":return 3;case"dead":return 4;default:return 5}}var r7n=.72,n7n={anchor:"center",width:"72%",minWidth:48,maxHeight:"72%",margin:1},o7n=[["crtr.graph.dismiss","dismiss"],["crtr.graph.focus","focus"],["crtr.graph.revive","revive"],["crtr.graph.close-subtree","close"],["crtr.graph.focus-manager","manager"]],i7n=[["crtr.graph.dismiss","dismiss"]],s7n=[["crtr.graph.down","down"],["crtr.graph.up","up"],["crtr.graph.first","first"],["crtr.graph.last","last"],["crtr.graph.collapse-or-manager","fold/manager"],["crtr.graph.expand-or-child","expand/child"]],ase=class{constructor(t,r,n,o,i,s,a="controller",l=()=>{},u=()=>{}){this.tui=t;this.self=r;this.getAsks=n;this.palette=o;this.bindings=i;this.source=s;this.role=a;this.onRefreshFailure=l;this.onNotice=u;this.remote=s instanceof Tb}tui;self;getAsks;palette;bindings;source;role;onRefreshFailure;onNotice;handle;userExpanded=new Set;userCollapsed=new Set;folds={userExpanded:this.userExpanded,userCollapsed:this.userCollapsed};cursorId;scrollTop=0;pendingConfirm;snapshot;snapshotSeq=0;remote;async fetchFocused(){if(this.remote)return new Set;try{return new Set((await Rs().listFocuses()).map(t=>t.node_id))}catch{return new Set}}setBindings(t){this.bindings=t,this.handle!==void 0&&this.tui.requestRender()}matches(t,r){return lI(this.bindings,t,r,(n,o)=>ln(n,o))}hint(t){return t.flatMap(([r,n])=>this.bindings.gestures(r).length===0?[]:[`${Zh(this.bindings,r)} ${n}`])}fitHints(t,r){let o=[],i=0;for(let s of this.hint(t)){let a=(o.length===0?0:3)+vb(s);if(i+a>r)break;o.push(s),i+=a}return o.join(" \xB7 ")}isOpen(){return this.handle!==void 0}open(){this.handle===void 0&&(this.scrollTop=0,this.pendingConfirm=void 0,this.cursorId=this.self,this.handle=this.tui.showOverlay(this,n7n),this.refresh())}close(){this.handle!==void 0&&(this.pendingConfirm=void 0,this.handle.hide(),this.handle=void 0,this.tui.requestRender())}toggle(){this.handle===void 0?this.open():this.close()}refresh(){if(this.handle===void 0)return;let t=++this.snapshotSeq;this.buildSnapshot().then(r=>{t===this.snapshotSeq&&(this.snapshot=r,this.tui.requestRender())}).catch(r=>{this.onRefreshFailure(r),this.tui.requestRender()})}invalidate(){this.refresh()}async buildSnapshot(){SV();let t=new Map,r=new Map,n=new Map,o=new Map,i=new Map,s=async _=>{if(t.has(_))return t.get(_)??null;let b;try{b=await this.source.getNode(_)}catch{b=null}return t.set(_,b),b},a=async _=>{let b=r.get(_);if(b!==void 0)return b;let S;try{S=await this.source.subscriptionsOf(_)}catch{S=[]}let v=[];for(let I of S)(await s(I.node_id))?.kind!=="human"&&v.push(I.node_id);return r.set(_,v),v},l=async _=>{if(o.has(_))return o.get(_);let b;try{b=(await this.source.subscribersOf(_))[0]?.node_id}catch{b=void 0}return o.set(_,b),b},u=async _=>{let b=_,S=new Set([b]);for(;;){let v=await l(b);if(v===void 0||S.has(v))break;S.add(v),b=v}return b},c=async _=>chr((await s(_))?.status),p=async _=>{let b=n.get(_);if(b!==void 0)return b;let S=[...await a(_)],v=new Map;return await Promise.all(S.map(async I=>{v.set(I,await c(I))})),S.sort((I,R)=>(v.get(I)??99)-(v.get(R)??99)),n.set(_,S),S},d=new Set,f=new Set,m=async _=>{let b=(await s(_))?.status==="active";if(f.has(_))return{active:b?1:0,reveal:b||_===this.self};f.add(_);let S=0,v=!1;for(let I of await a(_)){let R=await m(I);S+=R.active,R.reveal&&(v=!0)}return i.set(_,S),v&&d.add(_),{active:S+(b?1:0),reveal:v||b||_===this.self}};await m(await u(this.self));let h=[],g=new Set,y=async(_,b,S,v)=>{if(g.has(_)){let F=S?"":v?"\u2514\u2500 ":"\u251C\u2500 ";h.push({id:_,hasKids:!1,isSelf:_===this.self,branch:b+F,cycle:!0,collapsed:!1});return}g.add(_);let I=await p(_),R=this.folds.userExpanded.has(_)?!1:this.folds.userCollapsed.has(_)?!0:!d.has(_),N=S?"":v?"\u2514\u2500 ":"\u251C\u2500 ";if(h.push({id:_,hasKids:I.length>0,isSelf:_===this.self,branch:b+N,cycle:!1,collapsed:R}),R)return;let O=S?"":b+(v?" ":"\u2502 ");for(let F=0;F<I.length;F++)await y(I[F],O,!1,F===I.length-1)};return await y(await u(this.self),"",!0,!0),{rows:h,activeBelow:i,nodes:t,childIds:r,sortedChildIds:n,managerOf:o,focused:await this.fetchFocused()}}renderSnapshotRow(t,r,n,o){let i=(v,I)=>n?bV(v,Rb(),Yie):I?bV(v,Rb(),Afr):yV(v);if(r.cycle){let v=`${r.branch} ${Cp}\u21BA ${tse(r.id)}${Bs}`;return i(v,!1)}let s=t.nodes.get(r.id)??null,a=Jie(s,this.remote),l=TV(s,r.id),u=r.isSelf?`${AV}${l}${Bs}`:l,c=`${Cp}${s?.kind??""}${Bs}`,p=`${Cp}${Xie(r.id,this.remote)}${Bs}`,d=r.hasKids&&r.collapsed,f=!n&&d?`${Cp}\u25B8${Bs} `:" ",m=ese(s),h=t.childIds.get(r.id)?.length??0,g=s!==null&&h>0?` ${Cp}\u2933${h}${Bs}`:"",y=s!==null&&s.status!=="active"&&(t.activeBelow.get(s.node_id)??0)>0?` ${Kie}\u21E3${t.activeBelow.get(s.node_id)??0}${Bs}`:"",_=s!==null&&!this.remote?Hie(s,_V(r.id)):null,b=_!==null?` \xB7 ${Q$e(_)}`:"",S=`${r.branch}${f}${a} ${u} ${c} ${p}${m}${g}${y}${rse(r.id,o)}${Zie(r.id,s,this.remote)}${b}`;return i(S,Sfr(r.id,s,t.focused,this.remote))}render(t){SV();let r=this.getAsks(),n=this.snapshot?.rows??[],o=n.findIndex(y=>y.id===this.cursorId);o<0&&(o=n.findIndex(y=>y.id===this.self),o<0&&(o=0)),this.cursorId=n[o]?.id??this.self;let i=process.stdout.rows??_fr,s=Math.max(3,Math.min(Math.floor(i*r7n),i-2)),a=Math.max(2,Math.min(n.length,s-2)),l=a;for(let y=0;y<4;y++){o<this.scrollTop&&(this.scrollTop=o),o>=this.scrollTop+l&&(this.scrollTop=o-l+1),this.scrollTop=Math.max(0,Math.min(this.scrollTop,Math.max(0,n.length-l)));let _=a-(this.scrollTop>0?1:0)-(this.scrollTop+l<n.length?1:0);if(_===l)break;l=Math.max(1,_)}let u=Math.min(n.length,this.scrollTop+l),c=[];this.scrollTop>0&&c.push(`${Cp} \u2191 ${this.scrollTop} more${Bs}`);for(let y=this.scrollTop;y<u;y++){let _=this.snapshot;c.push(_!==void 0?this.renderSnapshotRow(_,n[y],y===o,r):"")}for(u<n.length&&c.push(`${Cp} \u2193 ${n.length-u} more${Bs}`);c.length<a;)c.push("");let p=n.length>0?`${AV}\u2317 canvas graph${Bs} ${Cp}(${o+1}/${n.length})${Bs}`:`${AV}\u2317 canvas graph${Bs} ${Cp}(loading)${Bs}`,d=this.pendingConfirm!==void 0?`${EV}${this.pendingConfirm.label} ${AV}${this.hint([["crtr.graph.confirm.accept","accept"],["crtr.graph.confirm.cancel","cancel"]]).join(" \xB7 ")}${Bs}`:`${Cp}${this.fitHints([...this.remote||this.role!=="controller"?i7n:o7n,...s7n],Math.max(1,t-5))}${Bs}`,f=this.palette.border,m=Math.max(1,t-4),h=c.map(y=>`${f("\u2502")} ${bn(y,m,"",!0)} ${f("\u2502")}`),g=this.palette.surface;return[this.borderRow("\u256D","\u256E",p,t),...h,this.borderRow("\u2570","\u256F",d,t)].map(g)}borderRow(t,r,n,o){let i=this.palette.border,s=Math.max(1,o-5),a=bn(n,s,"\u2026"),l=Math.max(0,o-5-vb(a));return`${i(`${t}\u2500`)} ${a} ${i("\u2500".repeat(l)+r)}`}handleInput(t){try{this.dispatch(t)}catch{}}dispatch(t){if(this.pendingConfirm!==void 0){if(this.matches("crtr.graph.confirm.accept",t)){let u=this.pendingConfirm.action;this.pendingConfirm=void 0,u()}else this.pendingConfirm=void 0;this.tui.requestRender();return}if(this.matches("crtr.graph.dismiss",t)){this.close();return}let r=this.snapshot,n=r?.rows??[],o=n.findIndex(u=>u.id===this.cursorId);o<0&&(o=Math.max(0,n.findIndex(u=>u.id===this.self)));let i=n[o],s=u=>u!==void 0&&!u.cycle,a=(u,c)=>{for(let p=u+c;p>=0&&p<n.length;p+=c)if(s(n[p]))return p;return u},l=u=>{let c=u===1?0:n.length-1;for(let p=c;p>=0&&p<n.length;p+=u)if(s(n[p]))return p;return o};if(this.matches("crtr.graph.down",t)){o=a(o,1),this.cursorId=n[o]?.id??this.cursorId,this.tui.requestRender();return}if(this.matches("crtr.graph.up",t)){o=a(o,-1),this.cursorId=n[o]?.id??this.cursorId,this.tui.requestRender();return}if(this.matches("crtr.graph.first",t)){this.cursorId=n[l(1)]?.id??this.cursorId,this.tui.requestRender();return}if(this.matches("crtr.graph.last",t)){this.cursorId=n[l(-1)]?.id??this.cursorId,this.tui.requestRender();return}if(this.matches("crtr.graph.collapse-or-manager",t)){if(i!==void 0&&i.hasKids&&!i.collapsed)this.userCollapsed.add(i.id),this.userExpanded.delete(i.id),this.refresh();else{let u=r?.managerOf.get(this.cursorId??this.self);u!==void 0&&n.some(c=>c.id===u)&&(this.cursorId=u),this.tui.requestRender()}return}if(this.matches("crtr.graph.expand-or-child",t)){if(i!==void 0&&i.collapsed&&i.hasKids)this.userExpanded.add(i.id),this.userCollapsed.delete(i.id),this.refresh();else if(i!==void 0&&i.hasKids){let u=r?.sortedChildIds.get(i.id)?.[0];u!==void 0&&(this.cursorId=u),this.tui.requestRender()}return}if(this.matches("crtr.graph.focus",t)){if(this.remote)return;if(this.role!=="controller"){this.onNotice("Read-only observer attach \u2014 cannot focus nodes");return}this.cursorId!==void 0&&this.focusTarget(this.cursorId),this.close();return}if(this.matches("crtr.graph.revive",t)){if(this.remote)return;if(this.role!=="controller"){this.onNotice("Read-only observer attach \u2014 cannot revive nodes");return}let u=this.cursorId,c=u===void 0?null:r?.nodes.get(u)??null;u!==void 0&&c!==null&&c.status!=="active"&&c.status!=="idle"&&(this.reviveTarget(u),this.close());return}if(this.matches("crtr.graph.focus-manager",t)){if(this.remote)return;if(this.role!=="controller"){this.onNotice("Read-only observer attach \u2014 cannot focus nodes");return}let u=r?.managerOf.get(this.self);u!==void 0&&(this.focusTarget(u),this.close());return}if(this.matches("crtr.graph.close-subtree",t)){if(this.remote)return;if(this.role!=="controller"){this.onNotice("Read-only observer attach \u2014 cannot close nodes");return}let u=this.cursorId??this.self,c=r?.nodes.get(u)??null,p=c!==null?z$e(c):tse(u);this.pendingConfirm={label:`kill ${p}?`,action:()=>this.shellCrtr(["node","lifecycle","close","--node",u])},this.tui.requestRender();return}}focusTarget(t){let r=["surface","node","focus",t],n=process.env.TMUX_PANE;n!==void 0&&n!==""&&r.push("--pane",n),this.shellCrtr(r)}reviveTarget(t){this.shellCrtr(["node","lifecycle","revive",t],()=>this.focusTarget(t))}shellCrtr(t,r){try{t7n("crtr",t,o=>{o==null&&r?.()}).stdin?.end()}catch{}}};var a7n=.72,c7n={anchor:"center",width:"72%",minWidth:48,maxHeight:"72%",margin:1},cse=class{constructor(t,r){this.tui=t;this.pal=r}tui;pal;handle;bindings;scrollTop=0;lastContentRows=1;setBindings(t){this.bindings=t,this.handle!==void 0&&this.tui.requestRender()}toggle(){this.handle===void 0?this.open():this.close()}close(){this.handle!==void 0&&(this.handle.hide(),this.handle=void 0,this.tui.requestRender())}isOpen(){return this.handle!==void 0}invalidate(){this.handle!==void 0&&this.tui.requestRender()}render(t){let r=Math.max(1,t);if(r<5)return this.renderNarrow(r);let n=r-4,o=this.body(n),i=Math.max(1,this.tui.terminal.rows),s=Math.max(3,Math.min(Math.floor(i*a7n),Math.max(3,i-2))),a=Math.max(1,s-2),l=o.length>a,u=l&&a>=3?a-2:1;this.lastContentRows=u,this.scrollTop=Math.max(0,Math.min(this.scrollTop,Math.max(0,o.length-u)));let c=Math.min(o.length,this.scrollTop+u),p=[];for(l&&a>=3&&p.push(this.scrollTop>0?this.pal.muted(`\u2191 ${this.scrollTop} more`):""),p.push(...o.slice(this.scrollTop,c)),l&&a>=3?p.push(c<o.length?this.pal.muted(`\u2193 ${o.length-c} more`):""):l&&a===2&&p.push(c<o.length?this.pal.muted(`\u2193 ${o.length-c} more`):"");p.length<a;)p.push("");let d=this.pal.bold(this.pal.accent("\u2328 attach help")),f=this.pal.muted("esc dismiss \xB7 \u2191\u2193 scroll");return[this.borderRow("\u256D","\u256E",d,r),...p.map(m=>this.boxRow(m,r)),this.borderRow("\u2570","\u256F",f,r)].map(this.pal.surface)}handleInput(t){return ln(t,"escape")||ln(t,"q")?(this.close(),!0):ln(t,"up")||this.matchesBinding("crtr.attach.scroll.up",t)?this.scrollBy(-1):ln(t,"down")||this.matchesBinding("crtr.attach.scroll.down",t)?this.scrollBy(1):ln(t,"pageUp")||this.matchesBinding("crtr.attach.scroll.page-up",t)?this.scrollBy(-this.pageSize()):ln(t,"pageDown")||this.matchesBinding("crtr.attach.scroll.page-down",t)?this.scrollBy(this.pageSize()):!0}open(){this.scrollTop=0,this.handle=this.tui.showOverlay(this,c7n)}matchesBinding(t,r){return this.bindings!==void 0&&lI(this.bindings,t,r,(n,o)=>ln(n,o))}scrollBy(t){let r=this.body(Math.max(1,this.tui.terminal.columns-4)).length,n=Math.max(0,r-this.lastContentRows),o=Math.max(0,Math.min(n,this.scrollTop+t));return o!==this.scrollTop&&(this.scrollTop=o,this.tui.requestRender()),!0}pageSize(){return Math.max(1,this.lastContentRows-1)}body(t){return this.lines().flatMap(r=>this.wrap(r.text,t).map(n=>this.style(n,r.style)))}lines(){let t=(s,a)=>({text:`${this.binding(s)} ${a}`,style:"plain"}),r=s=>({text:s,style:"heading"}),n=s=>({text:s,style:"muted"}),o={text:"",style:"plain"},i=w_.map(({actionId:s})=>{let a=mm.find(l=>l.id===s)?.name??s;return t(s,a)});return[r("Scrolling"),t("crtr.attach.scroll.up","scroll one line up"),t("crtr.attach.scroll.down","scroll one line down"),t("crtr.attach.scroll.page-up","scroll one page up"),t("crtr.attach.scroll.page-down","scroll one page down"),t("crtr.attach.scroll.live","return to the live bottom"),n("mouse wheel (when reporting is on)"),o,r("Selection and copy"),{text:"Drag with the mouse to select; releasing copies the selection to the clipboard (and the tmux buffer). Any key clears the highlight.",style:"plain"},{text:"prefix + [ tmux copy-mode selects and copies the visible screenful; it works even while mouse reporting is on.",style:"plain"},{text:"Option-drag (iTerm2, Terminal.app) or Shift-drag (Ghostty, Alacritty, xterm) makes a true terminal selection that bypasses mouse reporting.",style:"plain"},{text:"Block-copy commands copy transcript content and are not capped at one screenful.",style:"plain"},o,r("Copy commands"),{text:"/copy last assistant message.",style:"plain"},{text:`/copy-message (${this.binding("crtr.attach.copy.message")}) newest copyable block entirely visible in the viewport; otherwise the newest one that intersects it.`,style:"plain"},{text:"/copy-tool newest tool call's result text.",style:"plain"},{text:"/copy-transcript every copyable block's text, oldest to newest, joined by blank lines.",style:"plain"},o,r("Mouse"),{text:`/mouse or ${this.binding("crtr.attach.mouse.toggle")} toggle mouse reporting \u2014 wheel scrolling and viewer drag-select. Turning it off restores tmux drag-select and copy-mode mouse.`,style:"plain"},o,r("Other attach keys"),...i]}binding(t){return this.bindings===void 0?"Loading\u2026":Zh(this.bindings,t)}wrap(t,r){if(t==="")return[""];let n=Math.max(1,r),o=t.split(/\s+/),i=[],s="";for(let a of o){let l=s===""?a:`${s} ${a}`;s!==""&&on(l)>n?(i.push(bn(s,n,"",!0)),s=` ${a}`):s=l}return s!==""&&i.push(bn(s,n,"",!0)),i}style(t,r){switch(r){case"heading":return this.pal.bold(this.pal.accent(t));case"muted":return this.pal.muted(t);case"plain":return t}}boxRow(t,r){let n=r-4,o=bn(t,n,"",!0),i=" ".repeat(Math.max(0,n-on(o)));return`${this.pal.border("\u2502")} ${o}${i} ${this.pal.border("\u2502")}`}borderRow(t,r,n,o){if(o<6)return bn(`${this.pal.border(t)}${n}${this.pal.border(r)}`,o,"",!0);let i=o-5,s=bn(n,i,"\u2026"),a=Math.max(0,o-5-on(s));return`${this.pal.border(`${t}\u2500`)} ${s} ${this.pal.border("\u2500".repeat(a)+r)}`}renderNarrow(t){let r=bn(this.pal.bold(this.pal.accent("help")),t,"",!0);return[this.pal.surface(r)]}};import{basename as l7n}from"node:path";function lse(e){return l7n(e)}async function lhr(e,t){await Rs().resolveHumanTicket(lse(e),{responses:t})}async function uhr(e,t){await Rs().cancelHumanTicket(lse(e),t)}function phr(e){if(e instanceof Es)return e.status===503||e.code==="daemon_unavailable";let t=e?.code;return t==="ECONNREFUSED"||t==="ENOENT"}function X1(e){return phr(e)?"could not reach crtrd \u2014 nothing was submitted":e instanceof Es||e instanceof Error?e.message:String(e)}function dhr(e){return phr(e)?"unreachable":e instanceof Es&&e.status===409&&e.code==="ticket_already_resolved"?"already_resolved":e instanceof Es&&e.status===500&&e.code==="delivery_failed"?"delivery_failed":"other"}import{join as u7n}from"node:path";function Z1(e,t){let r=`${new Date().toISOString()} ${e} ${t}`.replaceAll(/[\r\n]/g," ");BW(u7n(ku(),"attach-invariants.log"),r)}var use=class{constructor(t,r,n){this.surface=t;this.focusTarget=r;this.getRows=n}surface;focusTarget;getRows;_focused=!1;get focused(){return this._focused}set focused(t){this._focused=t,bU(this.focusTarget)&&(this.focusTarget.focused=t)}get wantsKeyRelease(){return this.focusTarget.wantsKeyRelease??this.surface.wantsKeyRelease??!1}render(t){let r=Math.max(0,Math.floor(this.getRows())),n=Math.max(0,Math.floor(t));this.surface.setRows(r);let o=this.surface.render(n);o.length!==r&&X$e(`surface rendered ${o.length} rows; expected ${r}`);let i=o.slice(0,r).map(a=>on(a)>n?(X$e(`surface row is ${on(a)} cells wide; expected at most ${n}`),mhr(pm(a,0,n,!0),n)):mhr(a,n));for(;i.length<r;)i.push(" ".repeat(n));let s=p7n(i);return s>1?(X$e(`surface frame contains ${s} CURSOR_MARKER sequences; expected at most one`),i.map(d7n)):i}handleInput(t){(this.focusTarget.handleInput??this.surface.handleInput)?.call(this.focusTarget.handleInput===void 0?this.surface:this.focusTarget,t)}invalidate(){this.surface.invalidate()}};function mhr(e,t){return bn(e,t,"",!0)}function p7n(e){return e.reduce((t,r)=>t+r.split(fa).length-1,0)}function d7n(e){return e.split(fa).join("")}function X$e(e){Z1("surface-frame",e)}var m7n={anchor:"top-left",width:"100%",maxHeight:"100%",margin:0},f7n={anchor:"top-left",width:"100%",margin:0,nonCapturing:!0},dse=class{capabilities;tui;mouse;getRows;statusNotice;formatNotice;noticeMs;selfPane;stack=[];noticeOverlay;noticeTimer;constructor(t){this.tui=t.tui,this.mouse=t.mouse,this.getRows=t.rows,this.statusNotice=t.statusNotice,this.formatNotice=t.formatNotice,this.noticeMs=t.noticeMs,this.selfPane=t.selfPane,this.capabilities={...t.capabilities,selfPane:t.selfPane}}mount(t,r){this.disposeNotice();let n=new use(t,r?.focus??t,this.getRows),o=this.tui.showOverlay(n,m7n),i={component:t,frame:n,overlay:o,cleanup:[],closed:!1,closing:!1},s=this.stack[this.stack.length-1];return s&&s.overlay.setHidden(!0),this.stack.push(i),{close:()=>this.closeEntry(i,!0),invalidate:()=>{!i.closed&&this.stack[this.stack.length-1]===i&&this.tui.requestRender()},onClose:a=>{if(i.closed||i.closing){pse("onClose registered after surface close");return}i.cleanup.push(a)}}}notice(t,r){if(this.disposeNotice(),this.stack.length===0){this.statusNotice(t,r);return}if(t==="")return;let n=new eze(()=>this.formatNotice(t));this.noticeOverlay=this.tui.showOverlay(n,f7n),r?.sticky!==!0&&this.noticeMs>0&&(this.noticeTimer=setTimeout(()=>{this.noticeTimer=void 0,this.disposeNoticeOverlay()},this.noticeMs))}suspendForChild(){return this.tui.stop(),()=>{this.tui.start(),this.mouse.reassert(),this.tui.invalidate(),this.tui.requestRender(!0)}}async focusNode(t){if(!this.capabilities.canFocusNode||this.selfPane===void 0||this.selfPane==="")return this.notice("Focusing a node needs a local viewer in tmux"),!1;try{let r=await Rs().getNode(t),n=await edr(t,{pane:this.selfPane,cwd:r.cwd,name:r.name});return n.focused||this.notice("Could not open that node\u2019s viewer"),n.focused}catch(r){return this.notice(X1(r)),!1}}pickReviewFile(t){return new Promise(r=>{let n,o=!1,i=a=>{o||(o=!0,r(a),n.close())},s=Tie(t.candidates,t.cwd,a=>i(a),()=>i(null),a=>this.notice(a));n=this.mount(h7n(s.component,t.title),{focus:s.focus}),n.onClose(()=>i(null))})}handleWheel(t,r){return this.stack[this.stack.length-1]?.component.handleWheel?.(t,r)??!1}hasFullPaneSurface(){return this.stack.length>0}closeAll(){let t=this.stack[0];t&&this.closeEntry(t,!1),this.disposeNotice()}closeEntry(t,r){if(t.closed||t.closing)return;if(this.stack.indexOf(t)===-1){t.closed=!0;return}for(t.closing=!0;this.stack.indexOf(t)!==-1;){let i=this.stack.indexOf(t),s=this.stack[i+1];if(!s)break;this.closeEntry(s,!1)}try{t.overlay.hide()}catch(i){pse(`surface overlay hide failed: ${Z$e(i)}`)}for(let i of t.cleanup)try{let s=i();s&&typeof s.then=="function"&&s.catch(a=>pse(`surface cleanup failed: ${Z$e(a)}`))}catch(s){pse(`surface cleanup failed: ${Z$e(s)}`)}t.cleanup.length=0,t.closed=!0,t.closing=!1;let o=this.stack.indexOf(t);if(o!==-1&&this.stack.splice(o,1),r){this.disposeNotice();let i=this.stack[this.stack.length-1];i&&i.overlay.setHidden(!1)}}disposeNotice(){this.disposeNoticeOverlay(),this.noticeTimer!==void 0&&(clearTimeout(this.noticeTimer),this.noticeTimer=void 0)}disposeNoticeOverlay(){this.noticeOverlay?.hide(),this.noticeOverlay=void 0}},eze=class{constructor(t){this.text=t}text;render(t){return[bn(this.text(),Math.max(0,t),"",!0)]}invalidate(){}};function h7n(e,t){let r=0;return{setRows:n=>{r=Math.max(0,Math.floor(n))},render:n=>{let o=e.render(n),s=(t===void 0?o:[bn(t,Math.max(0,n),"",!0),...o.slice(1)]).slice(0,r);for(;s.length<r;)s.push("");return s},invalidate:()=>e.invalidate()}}function pse(e){Z1("surface-host",e)}function Z$e(e){return e instanceof Error?e.message:String(e)}import{watch as DXn}from"node:fs";function g7n(e,t){switch(t){case"gray":return`${oie}${e}${Cr}`;case"cyan":return`${cl}${e}${Cr}`;case"red":return`${wpr}${e}${Cr}`;case"yellow":return`${Su}${e}${Cr}`;default:return e}}var _7n={notify:"\u2709",decision:"\u25C6",context:"\u270E",error:"\u26A0",review:"\u25A4"},A7n={notify:"gray",decision:"cyan",context:"cyan",error:"red",review:"yellow"};function E7n(e){let t=Math.floor((Date.now()-new Date(e).getTime())/6e4);return t>=60?`${Math.floor(t/60)}h ago`:t>0?`${t}m ago`:"just now"}function fhr(e,t,r,n=Number.MAX_SAFE_INTEGER){if(e.length===0)return[` ${Xr}${nie}No pending interactions${Cr}`];let o=t-4,i=e.map((l,u)=>y7n(l,u,r,o)),s=b7n(i,r,Math.max(1,n-2)),a=[` ${zh}${e.length} pending${Cr}`,""];s.start>0&&a.push(` ${Xr}\u2191 ${s.start} above${Cr}`);for(let l=s.start;l<s.end;l++)a.push(...i[l]);return s.end<e.length&&a.push(` ${Xr}\u2193 ${e.length-s.end} below${Cr}`),a}function y7n(e,t,r,n){let o=e.kind==="deck"?e.interactionKind??"decision":"review",i=_7n[o]??"\xB7",s=E7n(e.blockedSince),a=t===r?`${cl}\u25B8${Cr} `:" ",l=Math.max(1,n-s.length-6),u=`${a}${g7n(i,A7n[o]??"cyan")} ${zh}${z2e(e.title||`(${e.id.slice(0,8)})`,l)}${Cr} ${Xr}${s}${Cr}`,c=e.claim?`claimed by ${e.claim.owner} \xB7 ${e.subtitle}`:e.subtitle;return[u,` ${Xr}${z2e(c,n-6)}${Cr}`]}function b7n(e,t,r){let n=Math.max(0,Math.min(t,e.length-1)),o=n+1,i=!0;for(;;){let s=o<e.length?{start:n,end:o+1}:void 0,a=n>0?{start:n-1,end:o}:void 0,u=(i?[s,a]:[a,s]).find(c=>c!==void 0&&S7n(e,c.start,c.end)<=r);if(u===void 0)break;n=u.start,o=u.end,i=!i}return{start:n,end:o}}function S7n(e,t,r){let n=t>0?1:0;for(let o=t;o<r;o++)n+=e[o].length;return r<e.length&&n++,n}function tze(e,t,r="list"){if(e<60||t<18)return{mode:"minimum",listWidth:e,detailWidth:0,height:t};if(e<96)return{mode:r,listWidth:e,detailWidth:e,height:t};let n=Math.max(30,Math.min(44,Math.floor(e/3)));return{mode:"two-column",listWidth:n,detailWidth:e-n-1,height:t}}import{readdirSync as U7n,statSync as Shr}from"node:fs";import{basename as bhr,join as F7n}from"node:path";import{existsSync as C7n,linkSync as IAc,mkdirSync as vAc,realpathSync as RAc,renameSync as wAc,unlinkSync as x7n,writeFileSync as NAc}from"node:fs";function wb(e){return`${e}/deck.json`}function eU(e){return`${e}/review.json`}function tU(e){return`${e}/response.json`}function xw(e){return`${e}/progress.json`}function gE(e){return`${e}/claim.json`}function hhr(e){return C7n(tU(e))}function rze(e){try{x7n(xw(e))}catch(t){if(t.code!=="ENOENT")throw t}}import{basename as FAc,dirname as BAc,isAbsolute as _hr,resolve as $Ac,sep as zAc}from"node:path";D2();D2();var ghr=["notify","decision","context","error","review"];var I7n=yn.object({id:yn.string().min(1),label:yn.string().min(1),description:yn.string().optional()}),v7n=yn.object({selectedOptionId:yn.string().optional(),selectedOptionIds:yn.array(yn.string()).optional(),freetext:yn.string().optional(),label:yn.string().optional()}),R7n=yn.object({id:yn.string().regex(/^[A-Za-z0-9_-]+$/,{error:"interaction id must match /^[A-Za-z0-9_-]+$/"}).min(1).max(64),title:yn.string().trim().min(1),subtitle:yn.string().trim().min(1),body:yn.string().optional(),bodyPath:yn.string().optional(),options:yn.array(I7n),multiSelect:yn.boolean().optional(),allowFreetext:yn.boolean().optional(),freetextLabel:yn.string().optional(),kind:yn.enum(ghr).optional(),preAnswered:v7n.optional()}),Ahr=yn.object({sessionName:yn.string().optional(),askedBy:yn.string().optional(),blockedSince:yn.string().optional(),nodeId:yn.string().optional()}),w7n=yn.object({title:yn.string().trim().min(1),source:Ahr.optional(),interactions:yn.array(R7n).min(1)}).superRefine((e,t)=>{let r=new Set;e.interactions.forEach((n,o)=>{n.body!==void 0&&n.bodyPath!==void 0&&t.addIssue({code:"custom",message:"body and bodyPath are mutually exclusive",path:["interactions",o]}),r.has(n.id)&&t.addIssue({code:"custom",message:`duplicate interaction id "${n.id}"`,path:["interactions",o,"id"]}),r.add(n.id)})}),N7n=Ahr,O7n=yn.object({schema:yn.literal("humanloop.review/v1"),file:yn.string().min(1).refine(_hr,"file must be absolute"),output:yn.string().min(1).refine(_hr,"output must be absolute"),title:yn.string().trim().min(1),subtitle:yn.string().trim().min(1),source:N7n,blockedSince:yn.string().datetime({offset:!0})}).strict();function xV(e){return w7n.parse(e)}function nze(e){return O7n.parse(e)}import{mkdirSync as k7n,watch as P7n,writeFileSync as YAc}from"node:fs";import{basename as D7n,dirname as Ehr,join as M7n}from"node:path";function IV(){return tce()}function L7n(){return M7n(ku(),"signals","inbox-activity")}function yhr(e){let t=L7n();try{k7n(Ehr(t),{recursive:!0,mode:448});let r=P7n(Ehr(t),(n,o)=>{String(o)===D7n(t)&&e()});return()=>r.close()}catch{return()=>{}}}function Thr(e){let t=fd(gE(e));if(!(t===null||typeof t.host!="string"||typeof t.claimedAt!="string"||typeof t.heartbeatAt!="string"))return{owner:t.host,claimedAt:t.claimedAt,heartbeatAt:t.heartbeatAt}}function B7n(e,t){let r=fd(wb(e));if(r===null)return null;let n;try{n=xV(r)}catch{return null}let o=n.interactions[0];if(o===void 0)return null;let i=n.source?.blockedSince;if(i===void 0)try{i=Shr(wb(e)).mtime.toISOString()}catch{return null}return{dir:e,id:t,kind:"deck",title:n.title,subtitle:o.subtitle,interactionKind:o.kind,interactionCount:n.interactions.length,source:n.source??{},blockedSince:i,claim:Thr(e)}}function $7n(e,t){let r=fd(eU(e));if(r===null)return null;let n;try{n=nze(r)}catch{return null}return{dir:e,id:t,kind:"review",title:n.title,subtitle:n.subtitle,file:n.file,output:n.output,source:n.source,blockedSince:n.blockedSince,claim:Thr(e)}}function mse(){let e;try{e=U7n(IV())}catch{return[]}let t=[];for(let r of e){let n=F7n(IV(),r);try{if(!Shr(n).isDirectory())continue}catch{continue}if(hhr(n))continue;let o=B7n(n,bhr(n))??$7n(n,bhr(n));o!==null&&t.push(o)}return t.sort((r,n)=>n.blockedSince.localeCompare(r.blockedSince)||r.id.localeCompare(n.id))}import{existsSync as fse,renameSync as z7n,unlinkSync as Chr,writeFileSync as xhr}from"node:fs";import{hostname as oze}from"node:os";import{randomUUID as G7n}from"node:crypto";var Q7n=3e4;function q7n(e){if(typeof e!="object"||e===null)return null;let t=e;return typeof t.token!="string"||typeof t.host!="string"||!Number.isInteger(t.pid)||typeof t.claimedAt!="string"||typeof t.heartbeatAt!="string"?null:{token:t.token,host:t.host,pid:t.pid,claimedAt:t.claimedAt,heartbeatAt:t.heartbeatAt,...typeof t.tmuxClient=="string"?{tmuxClient:t.tmuxClient}:{}}}function hse(e){return q7n(fd(gE(e)))}function V7n(e){try{return process.kill(e,0),!0}catch{return!1}}function W7n(e,t=Date.now(),r=oze()){if(e.host===r)return!V7n(e.pid);let n=Date.parse(e.heartbeatAt);return!Number.isFinite(n)||t-n>Q7n}function Ihr(e,t={}){return fm(`${e}/.ticket-lock`,()=>{if(fse(tU(e))||!fse(wb(e))&&!fse(eU(e)))return null;let r=gE(e),n=hse(e);if(n!==null&&!W7n(n,Date.now(),t.host??oze()))return null;fse(r)&&Chr(r);let o=t.host??oze(),i=t.pid??process.pid,s=(t.now??new Date).toISOString(),a={token:G7n(),host:o,pid:i,claimedAt:s,heartbeatAt:s,...t.tmuxClient?{tmuxClient:t.tmuxClient}:{}};return xhr(r,`${JSON.stringify(a)}
|
|
1540
1540
|
`,{flag:"wx",mode:384}),a})}function vhr(e,t,r=new Date){return fm(`${e}/.ticket-lock`,()=>{let n=hse(e);if(n===null||n.token!==t)return!1;let o=`${gE(e)}.${t}.heartbeat`;return xhr(o,`${JSON.stringify({...n,heartbeatAt:r.toISOString()})}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { abortSessionFromExtension, runRefreshAbort } from '../runtime/broker/extension-abort.js';
|
|
4
|
+
test('refresh extension abort clears Pi follow-ups before abort while an ordinary abort preserves them', () => {
|
|
5
|
+
const events = [];
|
|
6
|
+
const session = {
|
|
7
|
+
clearQueue: () => { events.push('clear'); },
|
|
8
|
+
abort: async () => { events.push('abort'); },
|
|
9
|
+
};
|
|
10
|
+
abortSessionFromExtension(session);
|
|
11
|
+
assert.deepEqual(events, ['abort'], 'ordinary extension abort leaves queued messages in the current conversation');
|
|
12
|
+
events.length = 0;
|
|
13
|
+
runRefreshAbort(() => abortSessionFromExtension(session));
|
|
14
|
+
assert.deepEqual(events, ['clear', 'abort'], 'refresh removes queued messages before stopping the discarded conversation');
|
|
15
|
+
events.length = 0;
|
|
16
|
+
abortSessionFromExtension(session);
|
|
17
|
+
assert.deepEqual(events, ['abort'], 'the refresh scope does not leak into a later critical abort');
|
|
18
|
+
});
|
|
@@ -434,6 +434,89 @@ test('autoDropCleanManagedWorktree drops a clean, zero-commit worktree', async (
|
|
|
434
434
|
});
|
|
435
435
|
// The same real-commit case close already protects: a worktree with actual
|
|
436
436
|
// unlanded work must NEVER be auto-dropped by push-final.
|
|
437
|
+
// #671 also makes the explicit close terminal: once the checkout was safely
|
|
438
|
+
// removed after remote delivery, close concludes the record rather than
|
|
439
|
+
// sending the node to a recovery command that cannot address an absent path.
|
|
440
|
+
test('closeManagedWorktree concludes an absent checkout whose branch is delivered to its base upstream (#671)', () => {
|
|
441
|
+
const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-close-'));
|
|
442
|
+
const remote = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-close-remote-'));
|
|
443
|
+
try {
|
|
444
|
+
run(['init', '-b', 'main'], repo);
|
|
445
|
+
run(['config', 'user.email', 'test@example.com'], repo);
|
|
446
|
+
run(['config', 'user.name', 'Test'], repo);
|
|
447
|
+
writeFileSync(join(repo, 'README.md'), 'hello\n');
|
|
448
|
+
run(['add', '.'], repo);
|
|
449
|
+
run(['commit', '-m', 'init'], repo);
|
|
450
|
+
run(['init', '--bare'], remote);
|
|
451
|
+
run(['remote', 'add', 'origin', remote], repo);
|
|
452
|
+
run(['push', '-u', 'origin', 'main'], repo);
|
|
453
|
+
const nodeId = 'wt-671-close';
|
|
454
|
+
const wt = createManagedWorktree(repo, nodeId);
|
|
455
|
+
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
456
|
+
writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
|
|
457
|
+
run(['add', '.'], wt.path);
|
|
458
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'delivered remotely'], wt.path);
|
|
459
|
+
const deliveredSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
460
|
+
run(['push', 'origin', `${wt.branch}:main`], wt.path);
|
|
461
|
+
run(['fetch', 'origin'], repo);
|
|
462
|
+
run(['worktree', 'remove', wt.path], repo);
|
|
463
|
+
const result = closeManagedWorktree(nodeId);
|
|
464
|
+
assert.equal(result.landed_sha, deliveredSha, 'close reports the already-delivered branch tip');
|
|
465
|
+
assert.equal(result.worktree_removed, true, 'the absent path is already removed');
|
|
466
|
+
assert.equal(result.branch_deleted, true, 'the redundant local branch is deleted with upstream proof');
|
|
467
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'closed');
|
|
468
|
+
assert.equal(getNode(nodeId)?.cwd, wt.repo_root, 'the closed record repoints revive to the durable base checkout');
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
rmSync(repo, { recursive: true, force: true });
|
|
472
|
+
rmSync(remote, { recursive: true, force: true });
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
// #671 review finding (Major): the recorded path being absent does not mean
|
|
476
|
+
// the branch has no live checkout — `git worktree move` can relocate it
|
|
477
|
+
// elsewhere. The branch must never be low-level-deleted while still checked
|
|
478
|
+
// out somewhere, even when its tip is already contained in the base upstream.
|
|
479
|
+
test('closeManagedWorktree refuses when the recorded path is absent but the branch is checked out elsewhere with uncommitted work', () => {
|
|
480
|
+
const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-moved-'));
|
|
481
|
+
const remote = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-moved-remote-'));
|
|
482
|
+
const movedPath = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-moved-dest-'));
|
|
483
|
+
rmSync(movedPath, { recursive: true, force: true });
|
|
484
|
+
try {
|
|
485
|
+
run(['init', '-b', 'main'], repo);
|
|
486
|
+
run(['config', 'user.email', 'test@example.com'], repo);
|
|
487
|
+
run(['config', 'user.name', 'Test'], repo);
|
|
488
|
+
writeFileSync(join(repo, 'README.md'), 'hello\n');
|
|
489
|
+
run(['add', '.'], repo);
|
|
490
|
+
run(['commit', '-m', 'init'], repo);
|
|
491
|
+
run(['init', '--bare'], remote);
|
|
492
|
+
run(['remote', 'add', 'origin', remote], repo);
|
|
493
|
+
run(['push', '-u', 'origin', 'main'], repo);
|
|
494
|
+
const nodeId = 'wt-671-moved';
|
|
495
|
+
const wt = createManagedWorktree(repo, nodeId);
|
|
496
|
+
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
497
|
+
writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
|
|
498
|
+
run(['add', '.'], wt.path);
|
|
499
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'delivered remotely'], wt.path);
|
|
500
|
+
run(['push', 'origin', `${wt.branch}:main`], wt.path);
|
|
501
|
+
run(['fetch', 'origin'], repo);
|
|
502
|
+
// Relocate the checkout — the recorded `wt.path` is now absent, but the
|
|
503
|
+
// branch is still live at `movedPath`, and unrelated new work is added
|
|
504
|
+
// there AFTER the move, so it is not reachable from origin/main.
|
|
505
|
+
run(['worktree', 'move', wt.path, movedPath], repo);
|
|
506
|
+
assert.equal(existsSync(wt.path), false, 'the recorded path must actually be absent for this test to be meaningful');
|
|
507
|
+
writeFileSync(join(movedPath, 'uncommitted-after-move.txt'), 'must not be lost\n');
|
|
508
|
+
assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'worktree_missing', 'a branch still checked out elsewhere must never be concluded from the absent recorded path alone');
|
|
509
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the record must stay open — the branch has a live checkout with uncommitted work');
|
|
510
|
+
const refCheck = gitSync(['rev-parse', '--verify', `refs/heads/${wt.branch}`], repo);
|
|
511
|
+
assert.equal(refCheck.status, 0, 'the branch must be preserved — it is still checked out');
|
|
512
|
+
assert.ok(existsSync(join(movedPath, 'uncommitted-after-move.txt')), 'the uncommitted work at the relocated checkout must remain intact');
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
515
|
+
rmSync(repo, { recursive: true, force: true });
|
|
516
|
+
rmSync(remote, { recursive: true, force: true });
|
|
517
|
+
rmSync(movedPath, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
437
520
|
test('autoDropCleanManagedWorktree refuses a worktree with a real commit ahead of base', async () => {
|
|
438
521
|
const { autoDropCleanManagedWorktree } = await import('../../worktree.js');
|
|
439
522
|
const nodeId = 'wt-auto-drop-real-work';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface AbortableSession {
|
|
2
|
+
clearQueue(): unknown;
|
|
3
|
+
abort(): Promise<unknown>;
|
|
4
|
+
}
|
|
5
|
+
/** Mark one synchronous ExtensionContext.abort() call as a refresh boundary. */
|
|
6
|
+
export declare function runRefreshAbort(abort: () => void): void;
|
|
7
|
+
/** Bound into Pi's extension context by the broker for every session generation. */
|
|
8
|
+
export declare function abortSessionFromExtension(session: AbortableSession): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// ExtensionContext.abort() reaches the broker through AgentSession's bound
|
|
2
|
+
// abortHandler. A refresh-yield is the one extension abort that must also clear
|
|
3
|
+
// Pi's queued steering/follow-up messages: those messages remain durable in the
|
|
4
|
+
// canvas inbox while intent='refresh', and the fresh kickoff replays them.
|
|
5
|
+
// Other aborts (notably a critical inbox entry) leave queues intact because the
|
|
6
|
+
// current conversation still owns those already-handed-off messages.
|
|
7
|
+
let refreshAbortScope = false;
|
|
8
|
+
/** Mark one synchronous ExtensionContext.abort() call as a refresh boundary. */
|
|
9
|
+
export function runRefreshAbort(abort) {
|
|
10
|
+
refreshAbortScope = true;
|
|
11
|
+
try {
|
|
12
|
+
abort();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
refreshAbortScope = false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Bound into Pi's extension context by the broker for every session generation. */
|
|
19
|
+
export function abortSessionFromExtension(session) {
|
|
20
|
+
if (refreshAbortScope)
|
|
21
|
+
session.clearQueue();
|
|
22
|
+
void session.abort();
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** What crtrd stored, as it stored it — the payload of a `node_named` frame. */
|
|
2
|
+
export interface NodeNamedNotice {
|
|
3
|
+
description: string;
|
|
4
|
+
icon: string;
|
|
5
|
+
editorLabel: string;
|
|
6
|
+
}
|
|
7
|
+
/** Register the broker's fan-out. One broker hosts one pi process, so the last
|
|
8
|
+
* registration wins; a second broker in one process is not a thing that
|
|
9
|
+
* exists. */
|
|
10
|
+
export declare function onNodeNamed(fn: (notice: NodeNamedNotice) => void): void;
|
|
11
|
+
/** Announce a generated name crtrd's guarded commit APPLIED. A no-op when no
|
|
12
|
+
* broker is listening — the same extension code also runs under test harnesses
|
|
13
|
+
* that host no client registry. */
|
|
14
|
+
export declare function publishNodeNamed(notice: NodeNamedNotice): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// node-named.ts — the in-process hop from the naming extension to the broker's
|
|
2
|
+
// client fan-out.
|
|
3
|
+
//
|
|
4
|
+
// A node's generated name is committed by the naming extension
|
|
5
|
+
// (`pi-extensions/broker-local.ts`), which runs INSIDE the broker's own pi
|
|
6
|
+
// process but holds no reference to the broker's client registry: pi loads
|
|
7
|
+
// extensions by path, so there is no constructor seam to pass one through. This
|
|
8
|
+
// module is that seam — one module-level slot, both ends resolving the same
|
|
9
|
+
// file in the same process.
|
|
10
|
+
//
|
|
11
|
+
// The frame it carries (`node_named`) exists because the name lands seconds
|
|
12
|
+
// AFTER the turn that triggered it: a consumer settling its display state on
|
|
13
|
+
// turn-end reads an unnamed node and has nothing left to re-read on. The
|
|
14
|
+
// committing side pushes instead.
|
|
15
|
+
let listener;
|
|
16
|
+
/** Register the broker's fan-out. One broker hosts one pi process, so the last
|
|
17
|
+
* registration wins; a second broker in one process is not a thing that
|
|
18
|
+
* exists. */
|
|
19
|
+
export function onNodeNamed(fn) {
|
|
20
|
+
listener = fn;
|
|
21
|
+
}
|
|
22
|
+
/** Announce a generated name crtrd's guarded commit APPLIED. A no-op when no
|
|
23
|
+
* broker is listening — the same extension code also runs under test harnesses
|
|
24
|
+
* that host no client registry. */
|
|
25
|
+
export function publishNodeNamed(notice) {
|
|
26
|
+
listener?.(notice);
|
|
27
|
+
}
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { jobDir } from '../../canvas/paths.js';
|
|
4
4
|
import { emitEvent } from '../../events/emit.js';
|
|
5
5
|
import { operationIdContext } from '../../events/operation-id.js';
|
|
6
|
+
import { abortSessionFromExtension } from './extension-abort.js';
|
|
6
7
|
const MAX_ADMITTED_REPLACEMENTS = 2;
|
|
7
8
|
const REPLACEMENT_DEADLINE_MS = 30_000;
|
|
8
9
|
const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
|
|
@@ -52,7 +53,12 @@ export class RebindDriver {
|
|
|
52
53
|
this.agentRunActiveValue = false;
|
|
53
54
|
this.deps.onSessionRebind();
|
|
54
55
|
this.deps.toolGroups.resetForSession();
|
|
55
|
-
await candidateSession.bindExtensions({
|
|
56
|
+
await candidateSession.bindExtensions({
|
|
57
|
+
uiContext: this.deps.uiContext(),
|
|
58
|
+
mode: 'rpc',
|
|
59
|
+
abortHandler: () => abortSessionFromExtension(candidateSession),
|
|
60
|
+
shutdownHandler: () => this.requestShutdown(),
|
|
61
|
+
});
|
|
56
62
|
if (ticket !== this.latestRebindRequest)
|
|
57
63
|
return;
|
|
58
64
|
this.removeInstalledSubscription();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentSession, AgentSessionEvent, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcSessionState, SessionEntry, SessionInfo, SettingsConfig, SessionStats } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { Api, ImageContent, Model } from '@earendil-works/pi-ai';
|
|
3
|
-
import type { BrokerErrorFrame } from '../../api/dto/broker.js';
|
|
3
|
+
import type { BrokerErrorFrame, BrokerNodeNamedFrame } from '../../api/dto/broker.js';
|
|
4
4
|
import type { ToolGroupSummary } from '../../shared/tool-groups.js';
|
|
5
5
|
/** pi's own RPC types, re-exported unchanged. `broker-protocol` stays the one
|
|
6
6
|
* import site for the wire union; the shapes themselves are pi's, versioned by
|
|
@@ -386,6 +386,15 @@ export interface ModelChangedFrame {
|
|
|
386
386
|
* promise instead of hanging it — absent on uncorrelated errors (engine drive
|
|
387
387
|
* errors, command-op failures, frame_overflow). There is NO `retryable` field. */
|
|
388
388
|
export type ErrorFrame = BrokerErrorFrame;
|
|
389
|
+
/** The `node_named` control frame. SOURCED from the shared
|
|
390
|
+
* `@north-light/crouter-api` contract for the same reason `error` is: Northlight
|
|
391
|
+
* Core reads this frame off its own attach, so the wire shape here and the shape
|
|
392
|
+
* it consumes are one compiler-checked type.
|
|
393
|
+
*
|
|
394
|
+
* Broadcast when the naming extension's GUARDED commit applied — a name a human
|
|
395
|
+
* already set never produces one. The in-process hop from the extension to this
|
|
396
|
+
* fan-out is `broker/node-named.ts`. */
|
|
397
|
+
export type NodeNamedFrame = BrokerNodeNamedFrame;
|
|
389
398
|
/** Result of a command op (§1.3): `for` echoes the op name, `ok` the
|
|
390
399
|
* outcome, `detail` an optional human-readable note. */
|
|
391
400
|
export interface AckFrame {
|
|
@@ -589,7 +598,7 @@ export interface ToolGroupSummaryFrame {
|
|
|
589
598
|
/** Everything the broker can send. Live `AgentSessionEvent`s are relayed
|
|
590
599
|
* verbatim (the broker adds nothing); the broker's own control frames carry
|
|
591
600
|
* non-colliding `type` discriminants. */
|
|
592
|
-
export type BrokerToClient = WelcomeFrame | WorkingActivityFrame | ModelChangedFrame | ErrorFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
|
|
601
|
+
export type BrokerToClient = WelcomeFrame | WorkingActivityFrame | ModelChangedFrame | ErrorFrame | NodeNamedFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
|
|
593
602
|
/** Encode one frame as a single newline-terminated JSON line. */
|
|
594
603
|
export declare function encodeFrame(frame: ClientToBroker | BrokerToClient): string;
|
|
595
604
|
/** Byte bounds for a {@link FrameDecoder} (C5). */
|
|
@@ -42,6 +42,7 @@ import { visibleMessages } from './session-visibility.js';
|
|
|
42
42
|
import { isUnknownModel } from './model-selection.js';
|
|
43
43
|
import { BrokerClientRegistry } from './broker/client-registry.js';
|
|
44
44
|
import { ToolGroupTracker } from './broker/tool-groups.js';
|
|
45
|
+
import { onNodeNamed } from './broker/node-named.js';
|
|
45
46
|
import { FaultRetry } from './broker/fault-retry.js';
|
|
46
47
|
import { EventProjection } from './broker/event-projection.js';
|
|
47
48
|
import { createFrameDispatchContext, handleFrame } from './broker/frame-dispatch.js';
|
|
@@ -275,6 +276,11 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
275
276
|
const registry = new BrokerClientRegistry(nodeId);
|
|
276
277
|
const pendingDialogs = new Map();
|
|
277
278
|
const toolGroups = new ToolGroupTracker(nodeId, (frame) => registry.broadcast(frame));
|
|
279
|
+
// The naming extension commits the node's generated name a few seconds into
|
|
280
|
+
// its first turn and hands it here (broker/node-named.ts). Every attached
|
|
281
|
+
// client learns it on the pipe it already holds instead of re-reading the node
|
|
282
|
+
// on some later, unrelated trigger.
|
|
283
|
+
onNodeNamed((notice) => registry.broadcast({ type: 'node_named', ...notice }));
|
|
278
284
|
let disposed = false;
|
|
279
285
|
let disposePending = false;
|
|
280
286
|
let server;
|
|
@@ -34,9 +34,9 @@ export declare function sanitizeIcon(raw: string): string;
|
|
|
34
34
|
* to a sanitized SessionName, or `{name:'',icon:''}` on timeout, a missing tool
|
|
35
35
|
* call, or a malformed emission so the caller can fall back to a local slug.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* This module stays canvas-free so CLI leaves can reach it. The canvas-coupled
|
|
38
|
+
* commit path lives in pi-extensions/broker-local.ts, which imports this pure
|
|
39
|
+
* helper and writes through crtrd's guarded generated-name operation. */
|
|
40
40
|
export declare function headlessName(body: string, sourceCap?: number): Promise<SessionName>;
|
|
41
41
|
/** Generate a name from a bounded conversation source rather than a single
|
|
42
42
|
* kickoff message. The caller owns selecting representative conversation text;
|
|
@@ -185,9 +185,9 @@ export function sanitizeIcon(raw) {
|
|
|
185
185
|
* to a sanitized SessionName, or `{name:'',icon:''}` on timeout, a missing tool
|
|
186
186
|
* call, or a malformed emission so the caller can fall back to a local slug.
|
|
187
187
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
188
|
+
* This module stays canvas-free so CLI leaves can reach it. The canvas-coupled
|
|
189
|
+
* commit path lives in pi-extensions/broker-local.ts, which imports this pure
|
|
190
|
+
* helper and writes through crtrd's guarded generated-name operation. */
|
|
191
191
|
export async function headlessName(body, sourceCap = PROMPT_CAP) {
|
|
192
192
|
const empty = { name: '', icon: '' };
|
|
193
193
|
const route = nameModelRequest();
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -38,6 +38,10 @@ export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktr
|
|
|
38
38
|
type WorktreeCleanupRunner = (args: string[], cwd: string, options: GitSyncOptions) => GitResult;
|
|
39
39
|
/** Test seam for the bounded post-land cleanup regression. */
|
|
40
40
|
export declare function setWorktreeCleanupRunnerForTest(runner?: WorktreeCleanupRunner): void;
|
|
41
|
+
/** Recovery guidance for a vanished checkout whose refs do not prove that its
|
|
42
|
+
* work is delivered. This deliberately never recommends `worktree close`:
|
|
43
|
+
* that command cannot operate on the absent checkout. */
|
|
44
|
+
export declare function missingWorktreeRecoveryHint(wt: ManagedWorktree): string;
|
|
41
45
|
interface CleanEmptyProof {
|
|
42
46
|
/** The branch's HEAD SHA at the moment this was proven clean-and-contained
|
|
43
47
|
* — the exact value the destructive delete must be conditioned on. */
|
package/dist/core/worktree.js
CHANGED
|
@@ -59,8 +59,15 @@ function resolveLandableBase(repoRoot, baseRef) {
|
|
|
59
59
|
* if it is not checked out in any worktree. */
|
|
60
60
|
function checkoutPathForBranch(repoRoot, branch) {
|
|
61
61
|
const out = runGit(repoRoot, ['worktree', 'list', '--porcelain'], 'worktree_list_failed', 'Inspect the repository worktrees manually, then retry.');
|
|
62
|
+
return checkoutPathForBranchInList(out, branch);
|
|
63
|
+
}
|
|
64
|
+
/** Shared parse for a `git worktree list --porcelain` blob already in hand —
|
|
65
|
+
* callers that must not throw on a read failure (an absent-path proof that
|
|
66
|
+
* needs to fail closed rather than error) call `gitSync` themselves and pass
|
|
67
|
+
* the output here instead of going through `checkoutPathForBranch`. */
|
|
68
|
+
function checkoutPathForBranchInList(porcelain, branch) {
|
|
62
69
|
let current = null;
|
|
63
|
-
for (const line of
|
|
70
|
+
for (const line of porcelain.split('\n')) {
|
|
64
71
|
if (line.startsWith('worktree '))
|
|
65
72
|
current = line.slice('worktree '.length).trim();
|
|
66
73
|
else if (line === `branch refs/heads/${branch}`)
|
|
@@ -256,7 +263,26 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
256
263
|
throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to close for this node.');
|
|
257
264
|
}
|
|
258
265
|
if (!existsSync(wt.path)) {
|
|
259
|
-
|
|
266
|
+
const proof = proveAbsentPathManagedWorktree(wt);
|
|
267
|
+
if (proof === null) {
|
|
268
|
+
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
|
|
269
|
+
}
|
|
270
|
+
const dropped = autoDropAbsentPathManagedWorktreeLocked(nodeId, wt, proof);
|
|
271
|
+
if (dropped === null) {
|
|
272
|
+
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
node_id: nodeId,
|
|
276
|
+
branch: wt.branch,
|
|
277
|
+
worktree_path: wt.path,
|
|
278
|
+
// No local fast-forward happened in this recovery path. When the branch
|
|
279
|
+
// still exists, this is its remotely/local-base-delivered tip; when it
|
|
280
|
+
// was already deleted, the recorded creation base is the only durable
|
|
281
|
+
// SHA left to report through the established close result contract.
|
|
282
|
+
landed_sha: proof.branchSha ?? wt.base_sha,
|
|
283
|
+
worktree_removed: true,
|
|
284
|
+
branch_deleted: true,
|
|
285
|
+
};
|
|
260
286
|
}
|
|
261
287
|
if (isRebaseInProgress(wt.path)) {
|
|
262
288
|
throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree close\`.`);
|
|
@@ -458,7 +484,13 @@ function bestEffortRemoveWorktreeCheckout(wt, options = {}) {
|
|
|
458
484
|
* simultaneously exactly what was proven, so a base rewrite can never leave
|
|
459
485
|
* a proven commit unreachable from any head. */
|
|
460
486
|
function bestEffortDeleteBranchWithBaseProof(wt, branchSha, baseSha) {
|
|
461
|
-
|
|
487
|
+
return bestEffortDeleteBranchWithContainmentProof(wt, branchSha, `refs/heads/${wt.base_ref}`, baseSha);
|
|
488
|
+
}
|
|
489
|
+
/** Delete a source branch only while the ref that proved it already delivered
|
|
490
|
+
* is still at the exact inspected SHA. `containingRef` can be the recorded
|
|
491
|
+
* local base or that base's remote-tracking upstream. */
|
|
492
|
+
function bestEffortDeleteBranchWithContainmentProof(wt, branchSha, containingRef, containingSha) {
|
|
493
|
+
const stdin = `verify ${containingRef} ${containingSha}\ndelete refs/heads/${wt.branch} ${branchSha}\n`;
|
|
462
494
|
const transaction = gitSync(['update-ref', '--stdin'], wt.repo_root, stdin);
|
|
463
495
|
const branchDeleted = transaction.status === 0;
|
|
464
496
|
if (branchDeleted)
|
|
@@ -469,6 +501,80 @@ function bestEffortDeleteBranchWithBaseProof(wt, branchSha, baseSha) {
|
|
|
469
501
|
branchDeleteError: `local branch ${wt.branch} could not be deleted automatically (${detail}); run \`git -C ${shellQuote(wt.repo_root)} branch -D ${shellQuote(wt.branch)}\` after confirming it is merged.`,
|
|
470
502
|
};
|
|
471
503
|
}
|
|
504
|
+
/** A missing checkout can still be concluded, but only from refs: either the
|
|
505
|
+
* recorded local branch is already gone, or its tip is contained in the
|
|
506
|
+
* recorded base or that base's upstream. Filesystem absence alone proves
|
|
507
|
+
* nothing about commits. */
|
|
508
|
+
function proveAbsentPathManagedWorktree(wt) {
|
|
509
|
+
if (wt.state !== 'open' || existsSync(wt.path))
|
|
510
|
+
return null;
|
|
511
|
+
const branchRef = `refs/heads/${wt.branch}`;
|
|
512
|
+
const branchExists = gitSync(['show-ref', '--verify', '--quiet', branchRef], wt.repo_root);
|
|
513
|
+
if (branchExists.status === 1)
|
|
514
|
+
return { branchSha: null };
|
|
515
|
+
if (branchExists.status !== 0)
|
|
516
|
+
return null;
|
|
517
|
+
const branch = gitSync(['rev-parse', '--verify', branchRef], wt.repo_root);
|
|
518
|
+
if (branch.status !== 0)
|
|
519
|
+
return null;
|
|
520
|
+
const branchSha = branch.stdout.trim();
|
|
521
|
+
// The recorded path being absent does NOT mean the branch has no live
|
|
522
|
+
// checkout: `git worktree move` (or a remove-then-re-add elsewhere) can
|
|
523
|
+
// relocate it to a different path while leaving `wt.path` gone. Deleting a
|
|
524
|
+
// branch that is still checked out somewhere else would bypass Git's own
|
|
525
|
+
// checked-out-branch protection and can abandon uncommitted work at that
|
|
526
|
+
// other path. Fail closed on any ambiguity from the inspection itself.
|
|
527
|
+
const worktreeList = gitSync(['worktree', 'list', '--porcelain'], wt.repo_root);
|
|
528
|
+
if (worktreeList.status !== 0)
|
|
529
|
+
return null;
|
|
530
|
+
if (checkoutPathForBranchInList(worktreeList.stdout, wt.branch) !== null)
|
|
531
|
+
return null;
|
|
532
|
+
const candidates = [];
|
|
533
|
+
const localBaseRef = `refs/heads/${wt.base_ref}`;
|
|
534
|
+
const localBaseExists = gitSync(['show-ref', '--verify', '--quiet', localBaseRef], wt.repo_root);
|
|
535
|
+
if (localBaseExists.status === 0) {
|
|
536
|
+
const localBase = gitSync(['rev-parse', '--verify', localBaseRef], wt.repo_root);
|
|
537
|
+
if (localBase.status !== 0)
|
|
538
|
+
return null;
|
|
539
|
+
candidates.push({ ref: localBaseRef, sha: localBase.stdout.trim() });
|
|
540
|
+
}
|
|
541
|
+
else if (localBaseExists.status !== 1) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
const upstream = gitSync(['rev-parse', '--symbolic-full-name', '--verify', `${wt.base_ref}@{upstream}`], wt.repo_root);
|
|
545
|
+
if (upstream.status === 0) {
|
|
546
|
+
const ref = upstream.stdout.trim();
|
|
547
|
+
const sha = gitSync(['rev-parse', '--verify', ref], wt.repo_root);
|
|
548
|
+
if (ref.startsWith('refs/') && sha.status === 0)
|
|
549
|
+
candidates.push({ ref, sha: sha.stdout.trim() });
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
// Managed bases normally track origin. Retained records from before that
|
|
553
|
+
// configuration existed can still prove delivery through origin/<base>.
|
|
554
|
+
const ref = `refs/remotes/origin/${wt.base_ref}`;
|
|
555
|
+
const exists = gitSync(['show-ref', '--verify', '--quiet', ref], wt.repo_root);
|
|
556
|
+
if (exists.status === 0) {
|
|
557
|
+
const sha = gitSync(['rev-parse', '--verify', ref], wt.repo_root);
|
|
558
|
+
if (sha.status === 0)
|
|
559
|
+
candidates.push({ ref, sha: sha.stdout.trim() });
|
|
560
|
+
}
|
|
561
|
+
else if (exists.status !== 1) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const candidate of candidates) {
|
|
566
|
+
if (gitSync(['merge-base', '--is-ancestor', branchSha, candidate.sha], wt.repo_root).status === 0) {
|
|
567
|
+
return { branchSha, containingRef: candidate.ref, containingSha: candidate.sha };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
/** Recovery guidance for a vanished checkout whose refs do not prove that its
|
|
573
|
+
* work is delivered. This deliberately never recommends `worktree close`:
|
|
574
|
+
* that command cannot operate on the absent checkout. */
|
|
575
|
+
export function missingWorktreeRecoveryHint(wt) {
|
|
576
|
+
return `The recorded checkout path is gone, but crtr cannot prove ${wt.branch} is already delivered. Push or land ${wt.branch}, then rerun \`crtr push final\`; if you cannot establish what happened to the branch, ask the human with \`crtr human ask\`.`;
|
|
577
|
+
}
|
|
472
578
|
/** Fix B's shared "nothing to land" test — the SAME clean-worktree definition
|
|
473
579
|
* `close`'s own dirty check now naturally satisfies once `.git/info/exclude`
|
|
474
580
|
* hides crtr's own `.crouter/config.json`/`state.json` bookkeeping (Fix A),
|
|
@@ -519,6 +625,25 @@ export function proveCleanEmptyManagedWorktree(wt) {
|
|
|
519
625
|
return null;
|
|
520
626
|
return { branchSha, baseSha };
|
|
521
627
|
}
|
|
628
|
+
/** Conclude an absent-path record only after `proveAbsentPathManagedWorktree`
|
|
629
|
+
* has established the same no-unlanded-work property required by auto-drop.
|
|
630
|
+
* There is no checkout left to remove; persistClosedWorktreeState writes the
|
|
631
|
+
* closed record and repoints cwd exactly like the normal tombstone path. */
|
|
632
|
+
function autoDropAbsentPathManagedWorktreeLocked(nodeId, wt, proof) {
|
|
633
|
+
if (existsSync(wt.path))
|
|
634
|
+
return null;
|
|
635
|
+
if (proof.branchSha === null) {
|
|
636
|
+
persistClosedWorktreeState(nodeId, wt);
|
|
637
|
+
return { node_id: nodeId, branch: wt.branch, worktree_path: wt.path, worktree_removed: true, branch_deleted: true };
|
|
638
|
+
}
|
|
639
|
+
if (proof.containingRef === undefined || proof.containingSha === undefined)
|
|
640
|
+
return null;
|
|
641
|
+
const { branchDeleted } = bestEffortDeleteBranchWithContainmentProof(wt, proof.branchSha, proof.containingRef, proof.containingSha);
|
|
642
|
+
if (!branchDeleted)
|
|
643
|
+
return null;
|
|
644
|
+
persistClosedWorktreeState(nodeId, wt);
|
|
645
|
+
return { node_id: nodeId, branch: wt.branch, worktree_path: wt.path, worktree_removed: true, branch_deleted: true };
|
|
646
|
+
}
|
|
522
647
|
/** Exported (see `proveCleanEmptyManagedWorktree`'s doc) so regression tests
|
|
523
648
|
* can call it directly with a proof captured before injecting a race.
|
|
524
649
|
* Returns null — persisting NO state change — when the immediate base
|
|
@@ -623,7 +748,8 @@ export function autoDropCleanManagedWorktree(nodeId) {
|
|
|
623
748
|
return null;
|
|
624
749
|
// A negative read-only preflight must fail before waiting on the repository
|
|
625
750
|
// mutation lock: real unlanded work should reach push-final's guard promptly.
|
|
626
|
-
|
|
751
|
+
const pathAbsent = !existsSync(wt.path);
|
|
752
|
+
if (pathAbsent ? proveAbsentPathManagedWorktree(wt) === null : proveCleanEmptyManagedWorktree(wt) === null)
|
|
627
753
|
return null;
|
|
628
754
|
return withRepositoryWorktreeLock(wt.repo_root, () => {
|
|
629
755
|
// Re-fetch inside the lock: state may have changed since the caller's
|
|
@@ -631,11 +757,13 @@ export function autoDropCleanManagedWorktree(nodeId) {
|
|
|
631
757
|
const locked = openManagedWorktreeForNode(nodeId);
|
|
632
758
|
if (locked === null)
|
|
633
759
|
return null;
|
|
634
|
-
//
|
|
635
|
-
//
|
|
760
|
+
// Re-prove the matching predicate under the lock before any state or ref
|
|
761
|
+
// mutation. A path that reappeared follows the ordinary checkout path.
|
|
762
|
+
if (!existsSync(locked.path)) {
|
|
763
|
+
const proof = proveAbsentPathManagedWorktree(locked);
|
|
764
|
+
return proof === null ? null : autoDropAbsentPathManagedWorktreeLocked(nodeId, locked, proof);
|
|
765
|
+
}
|
|
636
766
|
const proof = proveCleanEmptyManagedWorktree(locked);
|
|
637
|
-
|
|
638
|
-
return null;
|
|
639
|
-
return autoDropCleanManagedWorktreeLocked(nodeId, locked, proof);
|
|
767
|
+
return proof === null ? null : autoDropCleanManagedWorktreeLocked(nodeId, locked, proof);
|
|
640
768
|
});
|
|
641
769
|
}
|
|
@@ -198,6 +198,63 @@ test('auto-drop: `crtr push final` on a node with a CLEAN, zero-commit managed w
|
|
|
198
198
|
rmSync(repo, { recursive: true, force: true });
|
|
199
199
|
}
|
|
200
200
|
});
|
|
201
|
+
// #671: after a failed local close, a node may safely deliver its branch to
|
|
202
|
+
// the base's upstream and remove its own clean checkout. The path is gone, so
|
|
203
|
+
// only the remote-tracking ref can prove finalization will not discard work.
|
|
204
|
+
test('`crtr push final` concludes an absent managed worktree whose branch is delivered to its base upstream (#671)', async () => {
|
|
205
|
+
const repo = initRepo();
|
|
206
|
+
const remote = mkdtempSync(join(tmpdir(), 'crtr-leafparity-remote-'));
|
|
207
|
+
try {
|
|
208
|
+
git(['init', '--bare'], remote);
|
|
209
|
+
git(['remote', 'add', 'origin', remote], repo);
|
|
210
|
+
git(['push', '-u', 'origin', 'main'], repo);
|
|
211
|
+
const id = 'wt-671-upstream-delivered';
|
|
212
|
+
const wt = createManagedWorktree(repo, id);
|
|
213
|
+
writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
|
|
214
|
+
git(['add', '.'], wt.path);
|
|
215
|
+
git(['commit', '-m', 'delivered remotely'], wt.path);
|
|
216
|
+
const deliveredSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
217
|
+
// This is the incident delivery route: origin/main contains the branch,
|
|
218
|
+
// while the local base remains deliberately untouched.
|
|
219
|
+
git(['push', 'origin', `${wt.branch}:main`], wt.path);
|
|
220
|
+
git(['fetch', 'origin'], repo);
|
|
221
|
+
assert.equal(gitSync(['merge-base', '--is-ancestor', deliveredSha, 'origin/main'], repo).status, 0, 'the remote-tracking upstream must prove delivery');
|
|
222
|
+
git(['worktree', 'remove', wt.path], repo);
|
|
223
|
+
assert.equal(existsSync(wt.path), false, 'the incident shape has no checkout path left');
|
|
224
|
+
createNode(worktreeNode(id, wt.path, wt));
|
|
225
|
+
const out = await runPushAs(id, 'final', 'delivered remotely and cleaned up');
|
|
226
|
+
assert.equal(out['worktree_auto_dropped'], true, 'the absent-path proof must unblock finalization');
|
|
227
|
+
assert.equal(getNode(id)?.managed_worktree?.state, 'closed', 'the record is tombstoned as closed');
|
|
228
|
+
assert.equal(getNode(id)?.cwd, wt.repo_root, 'the closed record repoints revive to the durable base checkout');
|
|
229
|
+
assert.equal(getNode(id)?.status, 'done', 'finalization completes after the proven drop');
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
rmSync(repo, { recursive: true, force: true });
|
|
233
|
+
rmSync(remote, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
test('`crtr push final` keeps an absent managed worktree open when its branch is not delivered (#671)', async () => {
|
|
237
|
+
const repo = initRepo();
|
|
238
|
+
try {
|
|
239
|
+
const id = 'wt-671-absent-unlanded';
|
|
240
|
+
const wt = createManagedWorktree(repo, id);
|
|
241
|
+
writeFileSync(join(wt.path, 'unlanded.txt'), 'must remain reachable\n');
|
|
242
|
+
git(['add', '.'], wt.path);
|
|
243
|
+
git(['commit', '-m', 'unlanded work'], wt.path);
|
|
244
|
+
git(['worktree', 'remove', wt.path], repo);
|
|
245
|
+
createNode(worktreeNode(id, wt.path, wt));
|
|
246
|
+
await assert.rejects(() => runPushAs(id, 'final', 'must block'), (e) => e instanceof InputError
|
|
247
|
+
&& e.payload.error === 'open_managed_worktree'
|
|
248
|
+
&& typeof e.payload.next === 'string'
|
|
249
|
+
&& e.payload.next.includes(`Push or land ${wt.branch}`)
|
|
250
|
+
&& !e.payload.next.includes('node worktree close'), 'an absent checkout with unlanded commits must remain blocked with a usable recovery path');
|
|
251
|
+
assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the ambiguous record must stay open');
|
|
252
|
+
assert.equal(gitSync(['rev-parse', '--verify', `refs/heads/${wt.branch}`], repo).status, 0, 'the unlanded branch must remain reachable');
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
rmSync(repo, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
201
258
|
// ---------------------------------------------------------------------------
|
|
202
259
|
// `node worktree close` error parity — the SPECIFIC WorktreeError code + its
|
|
203
260
|
// `next` remediation round-trip verbatim through the server → ApiError → the
|
|
@@ -350,7 +350,12 @@ function handleGeneratedName(ctx) {
|
|
|
350
350
|
description: request.description,
|
|
351
351
|
...(request.icon.trim() === '' ? {} : { icon: request.icon }),
|
|
352
352
|
});
|
|
353
|
-
return {
|
|
353
|
+
return {
|
|
354
|
+
applied: true,
|
|
355
|
+
editorLabel: editorLabel(updated),
|
|
356
|
+
description: updated.description ?? '',
|
|
357
|
+
icon: updated.icon ?? '',
|
|
358
|
+
};
|
|
354
359
|
}
|
|
355
360
|
const expected = request.expected;
|
|
356
361
|
if (node.name !== expected.name || (node.description ?? '') !== expected.description
|
|
@@ -360,7 +365,12 @@ function handleGeneratedName(ctx) {
|
|
|
360
365
|
description: request.description,
|
|
361
366
|
icon: request.icon.trim() === '' ? undefined : request.icon,
|
|
362
367
|
});
|
|
363
|
-
return {
|
|
368
|
+
return {
|
|
369
|
+
applied: true,
|
|
370
|
+
editorLabel: editorLabel(updated),
|
|
371
|
+
description: updated.description ?? '',
|
|
372
|
+
icon: updated.icon ?? '',
|
|
373
|
+
};
|
|
364
374
|
});
|
|
365
375
|
return { status: 200, body: result };
|
|
366
376
|
}
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// /nodes/{id}/reports` lists the node's stored report files.
|
|
5
5
|
//
|
|
6
6
|
// `{id}` is the REPORTING node (client-side = CRTR_NODE_ID).
|
|
7
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { getNode } from '../../../index.js';
|
|
10
10
|
import { push, FinalizationError } from '../../../core/feed/feed.js';
|
|
11
|
-
import { autoDropCleanManagedWorktree, hasOpenManagedWorktree } from '../../../core/worktree.js';
|
|
11
|
+
import { autoDropCleanManagedWorktree, hasOpenManagedWorktree, missingWorktreeRecoveryHint } from '../../../core/worktree.js';
|
|
12
12
|
import { reportsDir } from '../../../core/canvas/paths.js';
|
|
13
13
|
import { notFound, usage } from '../../../core/errors.js';
|
|
14
14
|
import { InputError } from '../../../core/io.js';
|
|
@@ -58,7 +58,12 @@ async function handlePush(ctx) {
|
|
|
58
58
|
throw new InputError({
|
|
59
59
|
error: 'open_managed_worktree',
|
|
60
60
|
message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
|
|
61
|
-
next:
|
|
61
|
+
next: (() => {
|
|
62
|
+
const wt = getNode(id)?.managed_worktree;
|
|
63
|
+
return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
|
|
64
|
+
? missingWorktreeRecoveryHint(wt)
|
|
65
|
+
: 'Commit the remaining work in the worktree, run `crtr node worktree close`, then rerun `crtr push final`.';
|
|
66
|
+
})(),
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
69
|
autoDroppedPath = dropped.worktree_path;
|
|
@@ -9,6 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { CRTR_DIR_NAME } from '../types.js';
|
|
10
10
|
import { REVIVE_KICKOFF_SENTINEL } from '../shared/generated-context.js';
|
|
11
11
|
import { brokerExtensionState, commitBrokerGeneratedName, } from '../core/runtime/broker/daemon-ops.js';
|
|
12
|
+
import { publishNodeNamed } from '../core/runtime/broker/node-named.js';
|
|
12
13
|
import { headlessName, headlessNameFromConversation, sanitizeSessionName, slugFromPrompt, } from '../core/runtime/naming.js';
|
|
13
14
|
export { REVIVE_KICKOFF_SENTINEL };
|
|
14
15
|
function contextDir(nodeId) {
|
|
@@ -58,8 +59,20 @@ export function generateAndCommitInitialName(nodeId, prompt, onNamed) {
|
|
|
58
59
|
}));
|
|
59
60
|
})
|
|
60
61
|
.then((result) => {
|
|
61
|
-
if (result?.applied
|
|
62
|
-
|
|
62
|
+
if (!result?.applied || result.editorLabel === undefined)
|
|
63
|
+
return;
|
|
64
|
+
// Push the committed name down the attach stream BEFORE updating the live
|
|
65
|
+
// session label: both are the same delivery — the label reaches this
|
|
66
|
+
// process's own editor chip, the frame reaches every attached client
|
|
67
|
+
// (Northlight Core titles the conversation from it). The name lands
|
|
68
|
+
// seconds after the turn that triggered it, so a consumer with no frame
|
|
69
|
+
// has nothing left to re-read on.
|
|
70
|
+
publishNodeNamed({
|
|
71
|
+
description: result.description ?? '',
|
|
72
|
+
icon: result.icon ?? '',
|
|
73
|
+
editorLabel: result.editorLabel,
|
|
74
|
+
});
|
|
75
|
+
onNamed?.(result.editorLabel);
|
|
63
76
|
})
|
|
64
77
|
.catch(() => { });
|
|
65
78
|
}
|
|
@@ -32,6 +32,7 @@ import { emitEvent } from '../core/events/emit.js';
|
|
|
32
32
|
import { activeBackgroundBashJobs } from '../core/bash-jobs.js';
|
|
33
33
|
import { CONTEXT_NUDGE_CUSTOM_TYPE, formatContextNudge, formatPersonaTransition } from '../shared/generated-context.js';
|
|
34
34
|
import { isReviewBoundary } from '../core/runtime/session-visibility.js';
|
|
35
|
+
import { runRefreshAbort } from '../core/runtime/broker/extension-abort.js';
|
|
35
36
|
/** Condense a tool call into one short line for the activity cue. Picks the most
|
|
36
37
|
* identifying arg (command / path / pattern / description), collapses whitespace,
|
|
37
38
|
* and hard-caps the length so a giant bash command can't bloat telemetry.json. */
|
|
@@ -479,11 +480,14 @@ export function registerCanvasStophook(pi) {
|
|
|
479
480
|
touchBusy(nodeId); // wedge heartbeat: the tool returning is a sign of life
|
|
480
481
|
clearFault(nodeId, { link: 'daemon→node' }); // a tool that was "stuck" just returned — the wedge notice was resolved
|
|
481
482
|
if ((await brokerExtensionState(nodeId)).node.intent === 'refresh') {
|
|
482
|
-
// Stop this turn immediately so the large context does not keep running
|
|
483
|
-
//
|
|
484
|
-
//
|
|
483
|
+
// Stop this turn immediately so the large context does not keep running.
|
|
484
|
+
// The broker clears Pi's queued steering/follow-up messages inside this
|
|
485
|
+
// refresh scope; their durable inbox entries stay uncommitted and the
|
|
486
|
+
// fresh kickoff replays them. Leave the broker alive through
|
|
487
|
+
// agent_settled: only a successful daemon settle directive may request
|
|
488
|
+
// Pi shutdown.
|
|
485
489
|
try {
|
|
486
|
-
ctx?.abort?.();
|
|
490
|
+
runRefreshAbort(() => ctx?.abort?.());
|
|
487
491
|
}
|
|
488
492
|
catch { /* ignore */ }
|
|
489
493
|
}
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.191",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.191",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { NodeMeta } from '../canvas/index.js';
|
|
2
|
-
/** Asynchronously generate a name for `prompt` and persist it to the node's
|
|
3
|
-
* meta as `description` + `icon` — only if the node has none yet (so a later
|
|
4
|
-
* message never clobbers it). Non-blocking: safe to call from inside a live pi
|
|
5
|
-
* event loop. Best-effort; swallows all errors. The slug fallback carries no
|
|
6
|
-
* icon: a locally derived name has no judgment behind it to pick a glyph from.
|
|
7
|
-
*
|
|
8
|
-
* `onNamed` (optional) fires with the freshly-persisted meta the moment the
|
|
9
|
-
* name lands — the canvas-goal-capture naming hook passes a callback that calls
|
|
10
|
-
* pi.setSessionName(editorLabel(meta)) so the LIVE editor label updates in the
|
|
11
|
-
* same session, instead of waiting for the next revive/cycle. */
|
|
12
|
-
export declare function generateAndPersistName(nodeId: string, prompt: string, onNamed?: (meta: NodeMeta) => void): void;
|
|
13
|
-
/** Re-name an automatically titled node from its current conversation. The caller
|
|
14
|
-
* supplies the guard that distinguishes the original generated editor title from
|
|
15
|
-
* a title a person changed while this headless request was in flight. */
|
|
16
|
-
export declare function regenerateAndPersistName(nodeId: string, conversation: string, isStillAutomatic: () => boolean, onNamed?: (meta: NodeMeta) => void): void;
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Session naming — canvas-coupled persistence seam.
|
|
2
|
-
//
|
|
3
|
-
// The pure naming machinery (headless pi call + slug fallbacks) lives in
|
|
4
|
-
// naming.ts and stays canvas-free so CLI leaves can reach it without dragging
|
|
5
|
-
// in canvas state. This one function is the only naming path that writes to a
|
|
6
|
-
// node's meta, so it lives here — broker-side (canvas-goal-capture), never on a
|
|
7
|
-
// CLI leaf's import graph.
|
|
8
|
-
import { headlessName, headlessNameFromConversation, sanitizeSessionName, slugFromPrompt } from './naming.js';
|
|
9
|
-
import { getNode, updateNode } from '../canvas/index.js';
|
|
10
|
-
/** Asynchronously generate a name for `prompt` and persist it to the node's
|
|
11
|
-
* meta as `description` + `icon` — only if the node has none yet (so a later
|
|
12
|
-
* message never clobbers it). Non-blocking: safe to call from inside a live pi
|
|
13
|
-
* event loop. Best-effort; swallows all errors. The slug fallback carries no
|
|
14
|
-
* icon: a locally derived name has no judgment behind it to pick a glyph from.
|
|
15
|
-
*
|
|
16
|
-
* `onNamed` (optional) fires with the freshly-persisted meta the moment the
|
|
17
|
-
* name lands — the canvas-goal-capture naming hook passes a callback that calls
|
|
18
|
-
* pi.setSessionName(editorLabel(meta)) so the LIVE editor label updates in the
|
|
19
|
-
* same session, instead of waiting for the next revive/cycle. */
|
|
20
|
-
export function generateAndPersistName(nodeId, prompt, onNamed) {
|
|
21
|
-
const body = (prompt ?? '').trim();
|
|
22
|
-
if (body === '')
|
|
23
|
-
return;
|
|
24
|
-
const persist = (generated) => {
|
|
25
|
-
try {
|
|
26
|
-
const meta = getNode(nodeId);
|
|
27
|
-
if (meta === null)
|
|
28
|
-
return;
|
|
29
|
-
if ((meta.description ?? '').trim() !== '')
|
|
30
|
-
return; // already named
|
|
31
|
-
const clean = sanitizeSessionName(generated.name);
|
|
32
|
-
const description = clean !== '' ? clean : slugFromPrompt(body);
|
|
33
|
-
const icon = generated.icon.trim();
|
|
34
|
-
const updated = updateNode(nodeId, { description, ...(icon !== '' ? { icon } : {}) });
|
|
35
|
-
onNamed?.(updated);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// best-effort
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
void headlessName(body).then(persist);
|
|
42
|
-
}
|
|
43
|
-
/** Re-name an automatically titled node from its current conversation. The caller
|
|
44
|
-
* supplies the guard that distinguishes the original generated editor title from
|
|
45
|
-
* a title a person changed while this headless request was in flight. */
|
|
46
|
-
export function regenerateAndPersistName(nodeId, conversation, isStillAutomatic, onNamed) {
|
|
47
|
-
const body = (conversation ?? '').trim();
|
|
48
|
-
if (body === '')
|
|
49
|
-
return;
|
|
50
|
-
const persist = (generated) => {
|
|
51
|
-
try {
|
|
52
|
-
if (!isStillAutomatic())
|
|
53
|
-
return;
|
|
54
|
-
const meta = getNode(nodeId);
|
|
55
|
-
if (meta === null)
|
|
56
|
-
return;
|
|
57
|
-
const clean = sanitizeSessionName(generated.name);
|
|
58
|
-
const description = clean !== '' ? clean : slugFromPrompt(body);
|
|
59
|
-
const icon = generated.icon.trim();
|
|
60
|
-
const updated = updateNode(nodeId, { description, icon: icon !== '' ? icon : undefined });
|
|
61
|
-
onNamed?.(updated);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
// best-effort
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
void headlessNameFromConversation(body).then(persist);
|
|
68
|
-
}
|