ai-or-die 0.1.88 → 0.1.90
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/bin/ai-or-die.js +1 -0
- package/mesh/main.go +115 -3
- package/mesh/main_test.go +9 -5
- package/mesh-sidecar.lock.json +7 -7
- package/package.json +1 -1
- package/src/artifact-review.js +159 -2
- package/src/base-bridge.js +18 -0
- package/src/control/routes.js +4 -0
- package/src/mesh-manager.js +124 -22
- package/src/public/components/artifact-panel.css +57 -0
- package/src/server.js +55 -1
package/bin/ai-or-die.js
CHANGED
|
@@ -216,6 +216,7 @@ async function main() {
|
|
|
216
216
|
const mesh = new MeshManager({
|
|
217
217
|
port,
|
|
218
218
|
dev: options.dev,
|
|
219
|
+
authToken,
|
|
219
220
|
onUrl: (meshUrl) => {
|
|
220
221
|
const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
|
|
221
222
|
console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
|
package/mesh/main.go
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// MESH-URL https://<name> node is up, serving real TLS at the edge
|
|
18
18
|
// MESH-URL http://<name> node is up, but TLS certs unavailable (degraded)
|
|
19
19
|
// MESH-NOCERT hint: enable HTTPS Certificates in the tailnet
|
|
20
|
+
// MESH-PEERS {"self":...,"peers":[...]} tagged fleet peers snapshot
|
|
20
21
|
// MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
|
|
21
22
|
// MESH-ERR <msg> fatal
|
|
22
23
|
package main
|
|
@@ -24,6 +25,7 @@ package main
|
|
|
24
25
|
import (
|
|
25
26
|
"context"
|
|
26
27
|
"crypto/tls"
|
|
28
|
+
"encoding/json"
|
|
27
29
|
"flag"
|
|
28
30
|
"fmt"
|
|
29
31
|
"net"
|
|
@@ -31,16 +33,23 @@ import (
|
|
|
31
33
|
"net/http/httputil"
|
|
32
34
|
"net/url"
|
|
33
35
|
"os"
|
|
36
|
+
"sort"
|
|
34
37
|
"strings"
|
|
35
38
|
"time"
|
|
36
39
|
|
|
40
|
+
"tailscale.com/ipn/ipnstate"
|
|
37
41
|
"tailscale.com/tsnet"
|
|
38
42
|
)
|
|
39
43
|
|
|
40
44
|
// version is stamped at build time via -ldflags "-X main.version=<contentHash>".
|
|
41
45
|
var version = "dev"
|
|
42
46
|
|
|
43
|
-
const
|
|
47
|
+
const (
|
|
48
|
+
certWaitTimeout = 20 * time.Second
|
|
49
|
+
meshPeersInterval = 20 * time.Second
|
|
50
|
+
meshPeersMaxPeers = 512
|
|
51
|
+
meshPeerTag = "tag:aiordie"
|
|
52
|
+
)
|
|
44
53
|
|
|
45
54
|
func main() {
|
|
46
55
|
port := flag.String("port", "7777", "local ai-or-die port to expose (loopback)")
|
|
@@ -55,6 +64,8 @@ func main() {
|
|
|
55
64
|
return
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
proxyBearer := os.Getenv("AIORDIE_PROXY_BEARER")
|
|
68
|
+
|
|
58
69
|
// Resolve + validate the backend target. It MUST be loopback: the sidecar is
|
|
59
70
|
// a tailnet-facing reverse proxy, and pointing it off-host would expose an
|
|
60
71
|
// arbitrary origin to the whole tailnet.
|
|
@@ -80,6 +91,10 @@ func main() {
|
|
|
80
91
|
os.Exit(1)
|
|
81
92
|
}
|
|
82
93
|
|
|
94
|
+
if lc, err := s.LocalClient(); err == nil {
|
|
95
|
+
go emitMeshPeersLoop(context.Background(), lc)
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
name := *host
|
|
84
99
|
if st != nil && st.Self != nil && st.Self.DNSName != "" {
|
|
85
100
|
name = strings.TrimSuffix(st.Self.DNSName, ".")
|
|
@@ -91,7 +106,7 @@ func main() {
|
|
|
91
106
|
// so wss works regardless; this header is hygiene for any absolute-URL or
|
|
92
107
|
// redirect the app generates (no mixed content).
|
|
93
108
|
edgeProto := "http"
|
|
94
|
-
rp := newEdgeProxy(target, name, &edgeProto)
|
|
109
|
+
rp := newEdgeProxy(target, name, &edgeProto, proxyBearer)
|
|
95
110
|
|
|
96
111
|
// Decide the scheme BEFORE advertising. tsnet's ListenTLS returns a listener
|
|
97
112
|
// without provisioning the cert; the ACME work (and any failure) happens
|
|
@@ -163,6 +178,94 @@ func isLoopbackHost(h string) bool {
|
|
|
163
178
|
return ip != nil && ip.IsLoopback()
|
|
164
179
|
}
|
|
165
180
|
|
|
181
|
+
type statusClient interface {
|
|
182
|
+
Status(context.Context) (*ipnstate.Status, error)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type meshPeersSnapshot struct {
|
|
186
|
+
Self meshPeerSelf `json:"self"`
|
|
187
|
+
Peers []meshPeer `json:"peers"`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type meshPeerSelf struct {
|
|
191
|
+
Hostname string `json:"hostname"`
|
|
192
|
+
DNSName string `json:"dnsName"`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type meshPeer struct {
|
|
196
|
+
Hostname string `json:"hostname"`
|
|
197
|
+
DNSName string `json:"dnsName"`
|
|
198
|
+
Online bool `json:"online"`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func emitMeshPeersLoop(ctx context.Context, lc statusClient) {
|
|
202
|
+
emitMeshPeers(ctx, lc)
|
|
203
|
+
ticker := time.NewTicker(meshPeersInterval)
|
|
204
|
+
defer ticker.Stop()
|
|
205
|
+
for {
|
|
206
|
+
select {
|
|
207
|
+
case <-ctx.Done():
|
|
208
|
+
return
|
|
209
|
+
case <-ticker.C:
|
|
210
|
+
emitMeshPeers(ctx, lc)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func emitMeshPeers(ctx context.Context, lc statusClient) {
|
|
216
|
+
status, err := lc.Status(ctx)
|
|
217
|
+
if err != nil || status == nil {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
line := meshPeersFromStatus(status)
|
|
221
|
+
b, err := json.Marshal(line)
|
|
222
|
+
if err != nil {
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
fmt.Printf("MESH-PEERS %s\n", b)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func meshPeersFromStatus(status *ipnstate.Status) meshPeersSnapshot {
|
|
229
|
+
var self meshPeerSelf
|
|
230
|
+
if status.Self != nil {
|
|
231
|
+
self = meshPeerSelf{Hostname: status.Self.HostName, DNSName: normalizeDNSName(status.Self.DNSName)}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
peers := make([]meshPeer, 0)
|
|
235
|
+
for _, ps := range status.Peer {
|
|
236
|
+
if ps == nil || !peerHasTag(ps, meshPeerTag) {
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
peers = append(peers, meshPeer{Hostname: ps.HostName, DNSName: normalizeDNSName(ps.DNSName), Online: ps.Online})
|
|
240
|
+
}
|
|
241
|
+
sort.Slice(peers, func(i, j int) bool {
|
|
242
|
+
if peers[i].DNSName != peers[j].DNSName {
|
|
243
|
+
return peers[i].DNSName < peers[j].DNSName
|
|
244
|
+
}
|
|
245
|
+
return peers[i].Hostname < peers[j].Hostname
|
|
246
|
+
})
|
|
247
|
+
if len(peers) > meshPeersMaxPeers {
|
|
248
|
+
peers = peers[:meshPeersMaxPeers]
|
|
249
|
+
}
|
|
250
|
+
return meshPeersSnapshot{Self: self, Peers: peers}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func peerHasTag(ps *ipnstate.PeerStatus, tag string) bool {
|
|
254
|
+
if ps.Tags == nil {
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
for i := 0; i < ps.Tags.Len(); i++ {
|
|
258
|
+
if ps.Tags.At(i) == tag {
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func normalizeDNSName(s string) string {
|
|
266
|
+
return strings.ToLower(strings.TrimSuffix(s, "."))
|
|
267
|
+
}
|
|
268
|
+
|
|
166
269
|
// tryListenTLS returns a TLS listener on :443 only when a cert for `name` looks
|
|
167
270
|
// obtainable; otherwise nil. It first checks the tailnet's advertised cert
|
|
168
271
|
// domains, then proactively warms the cert with a bounded timeout so a failure
|
|
@@ -214,13 +317,22 @@ func redirectToHTTPS(name string) http.HandlerFunc {
|
|
|
214
317
|
// X-Forwarded-Host so the app never emits mixed-content links. For an https
|
|
215
318
|
// loopback backend (a future self-signed local listener) it skips verification —
|
|
216
319
|
// the host is already validated as loopback, so this is not a trust boundary.
|
|
217
|
-
func newEdgeProxy(target *url.URL, name string, proto *string) *httputil.ReverseProxy {
|
|
320
|
+
func newEdgeProxy(target *url.URL, name string, proto *string, proxyBearer string) *httputil.ReverseProxy {
|
|
218
321
|
rp := httputil.NewSingleHostReverseProxy(target)
|
|
219
322
|
orig := rp.Director
|
|
220
323
|
rp.Director = func(r *http.Request) {
|
|
221
324
|
orig(r)
|
|
222
325
|
r.Header.Set("X-Forwarded-Proto", *proto)
|
|
223
326
|
r.Header.Set("X-Forwarded-Host", name)
|
|
327
|
+
// Authenticate tailnet->loopback traffic to the app's bearer middleware.
|
|
328
|
+
// Set REPLACES any client-supplied Authorization (no spoofing). When no
|
|
329
|
+
// token is configured, DELETE a client-supplied Authorization so a tailnet
|
|
330
|
+
// caller can never smuggle one through to the loopback app.
|
|
331
|
+
if proxyBearer != "" {
|
|
332
|
+
r.Header.Set("Authorization", "Bearer "+proxyBearer)
|
|
333
|
+
} else {
|
|
334
|
+
r.Header.Del("Authorization")
|
|
335
|
+
}
|
|
224
336
|
}
|
|
225
337
|
if target.Scheme == "https" {
|
|
226
338
|
rp.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
package/mesh/main_test.go
CHANGED
|
@@ -67,20 +67,21 @@ func TestIsLoopbackHost(t *testing.T) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// End-to-end through the actual reverse proxy (no tailnet): a real loopback
|
|
70
|
-
// backend must receive the X-Forwarded-Proto/Host the edge stamps,
|
|
71
|
-
// pointer-captured proto must reflect the post-TLS decision.
|
|
72
|
-
func
|
|
73
|
-
var gotProto, gotHost, gotBody string
|
|
70
|
+
// backend must receive the X-Forwarded-Proto/Host the edge stamps, the app
|
|
71
|
+
// bearer, and the pointer-captured proto must reflect the post-TLS decision.
|
|
72
|
+
func TestEdgeProxyForwardsProtoHostAndAuth(t *testing.T) {
|
|
73
|
+
var gotProto, gotHost, gotAuth, gotBody string
|
|
74
74
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
75
75
|
gotProto = r.Header.Get("X-Forwarded-Proto")
|
|
76
76
|
gotHost = r.Header.Get("X-Forwarded-Host")
|
|
77
|
+
gotAuth = r.Header.Get("Authorization")
|
|
77
78
|
io.WriteString(w, "backend-ok")
|
|
78
79
|
}))
|
|
79
80
|
defer backend.Close()
|
|
80
81
|
|
|
81
82
|
target, _ := url.Parse(backend.URL) // 127.0.0.1 loopback
|
|
82
83
|
proto := "http" // default before the TLS decision
|
|
83
|
-
rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto)
|
|
84
|
+
rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto, "app-token")
|
|
84
85
|
proto = "https" // edge decided real TLS AFTER constructing the proxy
|
|
85
86
|
|
|
86
87
|
front := httptest.NewServer(rp)
|
|
@@ -100,6 +101,9 @@ func TestEdgeProxyForwardsProtoAndHost(t *testing.T) {
|
|
|
100
101
|
if gotHost != "node.tailnet.ts.net" {
|
|
101
102
|
t.Errorf("backend saw X-Forwarded-Host=%q, want node.tailnet.ts.net", gotHost)
|
|
102
103
|
}
|
|
104
|
+
if gotAuth != "Bearer app-token" {
|
|
105
|
+
t.Errorf("backend saw Authorization=%q, want Bearer app-token", gotAuth)
|
|
106
|
+
}
|
|
103
107
|
if gotBody != "backend-ok" {
|
|
104
108
|
t.Errorf("proxy body=%q, want backend-ok", gotBody)
|
|
105
109
|
}
|
package/mesh-sidecar.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
|
-
"contentHash": "
|
|
3
|
+
"contentHash": "dbf60b5215ebb0b0947edf8fa47906280dd1fea12ef7e1181918fecfe7d9bcc0",
|
|
4
4
|
"assets": {
|
|
5
|
-
"aiordie-mesh-windows-amd64.exe": "
|
|
6
|
-
"aiordie-mesh-windows-arm64.exe": "
|
|
7
|
-
"aiordie-mesh-linux-amd64": "
|
|
8
|
-
"aiordie-mesh-linux-arm64": "
|
|
9
|
-
"aiordie-mesh-darwin-amd64": "
|
|
10
|
-
"aiordie-mesh-darwin-arm64": "
|
|
5
|
+
"aiordie-mesh-windows-amd64.exe": "3be314f27ddf69560cb34b40b70302609f28fe6feba7600580e68ce04a811a71",
|
|
6
|
+
"aiordie-mesh-windows-arm64.exe": "3169f41dc835522c42dc4766dcd9a608a65430fb3797120bcd1ae9e58014942c",
|
|
7
|
+
"aiordie-mesh-linux-amd64": "7fb0d9c624c85759fb20e789886d1f4e0e2b5621f8ee6105634c7e73fd44d049",
|
|
8
|
+
"aiordie-mesh-linux-arm64": "7d23eb2d8c4c9632f37ad8f11f8c8523ce7d6ce58d0607b4c73fb9ccbc261239",
|
|
9
|
+
"aiordie-mesh-darwin-amd64": "69336be9137a98d8a3409ea87702502fa82d573875041111ea46f65b2fd22442",
|
|
10
|
+
"aiordie-mesh-darwin-arm64": "f73bcb5a6107ac3c0da8a39f06af6620e2db6e66b4e3c135740d9081703fd938"
|
|
11
11
|
}
|
|
12
12
|
}
|
package/package.json
CHANGED
package/src/artifact-review.js
CHANGED
|
@@ -9,6 +9,9 @@ const { EventEmitter } = require('events');
|
|
|
9
9
|
const DEFAULT_POLL_HOLD_MS = 25000;
|
|
10
10
|
const DEFAULT_POLL_HEARTBEAT_MS = 5000;
|
|
11
11
|
const DEFAULT_SSE_HEARTBEAT_MS = 15000;
|
|
12
|
+
// Bound the artifact-push await so a stalled PTY write can't hold the /prompts
|
|
13
|
+
// HTTP response open. On timeout we treat the push as failed and re-queue.
|
|
14
|
+
const PUSH_TIMEOUT_MS = 4000;
|
|
12
15
|
|
|
13
16
|
function realpathOrResolve(file) {
|
|
14
17
|
let resolved = path.resolve(file);
|
|
@@ -81,6 +84,65 @@ function feedbackHasData(snapshot) {
|
|
|
81
84
|
);
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
// Render queued human prompts into a single message suitable for injecting into
|
|
88
|
+
// the CLI as a new user turn (the artifact-push path). Pure + exported so the
|
|
89
|
+
// gate decision and wording are unit-testable without a live PTY. Accepts both
|
|
90
|
+
// the panel's prompt objects ({prompt, text, sourceLine}) and bare-string prompts
|
|
91
|
+
// (curl/legacy). Returns '' when there is nothing actionable to push (caller
|
|
92
|
+
// skips injection on empty).
|
|
93
|
+
function normalizeFeedbackPrompt(p) {
|
|
94
|
+
if (typeof p === 'string') {
|
|
95
|
+
return p.trim() ? { prompt: p.trim() } : null;
|
|
96
|
+
}
|
|
97
|
+
if (p && typeof p.prompt === 'string' && p.prompt.trim()) {
|
|
98
|
+
return {
|
|
99
|
+
prompt: p.prompt.trim(),
|
|
100
|
+
text: typeof p.text === 'string' ? p.text : '',
|
|
101
|
+
sourceLine: typeof p.sourceLine === 'number' ? p.sourceLine : undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
function formatFeedbackForAgent(prompts) {
|
|
107
|
+
const items = (Array.isArray(prompts) ? prompts : [])
|
|
108
|
+
.map(normalizeFeedbackPrompt)
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
if (items.length === 0) return '';
|
|
111
|
+
const lines = items.map((p, i) => {
|
|
112
|
+
const quoted = p.text && p.text.trim()
|
|
113
|
+
? ' (re: "' + p.text.trim().slice(0, 160) + '")'
|
|
114
|
+
: '';
|
|
115
|
+
const where = typeof p.sourceLine === 'number' ? ' [line ' + p.sourceLine + ']' : '';
|
|
116
|
+
return (i + 1) + '.' + where + quoted + ' ' + p.prompt;
|
|
117
|
+
});
|
|
118
|
+
return (
|
|
119
|
+
'Human review feedback from the artifact panel (you were idle, so this was '
|
|
120
|
+
+ 'delivered as a new turn instead of through artifact_poll). Address these in '
|
|
121
|
+
+ 'the open artifact, then reply with the artifact_reply tool:\n'
|
|
122
|
+
+ lines.join('\n')
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Build the raw bytes to inject into the CLI PTY for an artifact push. Pure +
|
|
127
|
+
// exported so the security-sensitive sanitization is unit-testable. Strips ALL
|
|
128
|
+
// C0 control bytes and DEL except TAB and LF (this removes ESC, so the bracketed-
|
|
129
|
+
// paste markers and any other escape/CSI sequence in the human text cannot
|
|
130
|
+
// survive), which also drops CR so the only submit is the trailing CR we add.
|
|
131
|
+
// Wraps in a bracketed paste so multi-line feedback enters the composer
|
|
132
|
+
// atomically; size-capped to bound a single injection.
|
|
133
|
+
function buildArtifactPushPayload(text) {
|
|
134
|
+
if (!text || typeof text !== 'string') return '';
|
|
135
|
+
const safe = text.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ').slice(0, 4000);
|
|
136
|
+
return '\x1b[200~' + safe + '\x1b[201~\r';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Whether the artifact-push feature is enabled from its env var. Default ON:
|
|
140
|
+
// only an explicit falsy value (0 / false / off / no) disables it. Pure +
|
|
141
|
+
// exported so the default-on / opt-out contract is unit-tested.
|
|
142
|
+
function artifactPushEnabledFromEnv(raw) {
|
|
143
|
+
return !/^(0|false|off|no)$/i.test(String(raw == null ? '' : raw).trim());
|
|
144
|
+
}
|
|
145
|
+
|
|
84
146
|
class ArtifactReviewStore extends EventEmitter {
|
|
85
147
|
constructor() {
|
|
86
148
|
super();
|
|
@@ -214,6 +276,25 @@ class ArtifactReviewStore extends EventEmitter {
|
|
|
214
276
|
return review;
|
|
215
277
|
}
|
|
216
278
|
|
|
279
|
+
// Restore prompts that were claimed (acked) for a push that then failed, back to
|
|
280
|
+
// the FRONT of the queue so order is preserved (FIFO) ahead of any prompts that
|
|
281
|
+
// arrived during the push window. Re-emits 'feedback' so an in-flight poll picks
|
|
282
|
+
// them up. Used only by the artifact-push re-queue path.
|
|
283
|
+
restoreClaimedFeedback(aiSessionId, snapshot) {
|
|
284
|
+
const review = this._reviews.get(aiSessionId);
|
|
285
|
+
if (!review || !snapshot) return null;
|
|
286
|
+
const prompts = cloneArray(snapshot.prompts);
|
|
287
|
+
if (prompts.length > 0) {
|
|
288
|
+
review.queuedPrompts.unshift(...prompts);
|
|
289
|
+
if (snapshot.dom_snapshot !== undefined && review.domSnapshot == null) {
|
|
290
|
+
review.domSnapshot = snapshot.dom_snapshot;
|
|
291
|
+
}
|
|
292
|
+
review.updatedAt = nowIso();
|
|
293
|
+
this.emit('feedback', { aiSessionId, kind: 'prompts', review });
|
|
294
|
+
}
|
|
295
|
+
return review;
|
|
296
|
+
}
|
|
297
|
+
|
|
217
298
|
addAgentReply(aiSessionId, text) {
|
|
218
299
|
const review = this._reviews.get(aiSessionId);
|
|
219
300
|
if (!review) return null;
|
|
@@ -567,6 +648,27 @@ function createArtifactReviewRouter(options) {
|
|
|
567
648
|
const sseHeartbeatMs = typeof options.sseHeartbeatMs === 'number'
|
|
568
649
|
? options.sseHeartbeatMs
|
|
569
650
|
: DEFAULT_SSE_HEARTBEAT_MS;
|
|
651
|
+
// Optional artifact-push hook (default off). When provided, panel feedback that
|
|
652
|
+
// arrives while NO agent poll is in flight is pushed into the CLI as a new turn
|
|
653
|
+
// (so an idle agent reacts without the human switching to the terminal). The
|
|
654
|
+
// server supplies this only when AIORDIE_ARTIFACT_PUSH is enabled; it returns a
|
|
655
|
+
// truthy value when it actually injected, so we can consume the queued prompts.
|
|
656
|
+
const pushToAgent = typeof options.pushToAgent === 'function'
|
|
657
|
+
? options.pushToAgent
|
|
658
|
+
: null;
|
|
659
|
+
const pushTimeoutMs = typeof options.pushTimeoutMs === 'number' && options.pushTimeoutMs > 0
|
|
660
|
+
? options.pushTimeoutMs
|
|
661
|
+
: PUSH_TIMEOUT_MS;
|
|
662
|
+
|
|
663
|
+
// Count of in-flight long-poll requests per session. Non-zero means the agent
|
|
664
|
+
// is actively waiting on artifact_poll, so a queued prompt is delivered by that
|
|
665
|
+
// poll and must NOT be injected (injecting mid-turn would race the CLI's TUI).
|
|
666
|
+
const activePolls = new Map();
|
|
667
|
+
function pollDelta(sessionId, delta) {
|
|
668
|
+
const next = (activePolls.get(sessionId) || 0) + delta;
|
|
669
|
+
if (next > 0) activePolls.set(sessionId, next);
|
|
670
|
+
else activePolls.delete(sessionId);
|
|
671
|
+
}
|
|
570
672
|
|
|
571
673
|
if (!store) throw new TypeError('Artifact review store is required');
|
|
572
674
|
if (typeof validatePath !== 'function') throw new TypeError('validatePath is required');
|
|
@@ -731,15 +833,60 @@ function createArtifactReviewRouter(options) {
|
|
|
731
833
|
res.sendFile(resolved.path);
|
|
732
834
|
});
|
|
733
835
|
|
|
734
|
-
router.post('/:sessionId/prompts', (req, res) => {
|
|
836
|
+
router.post('/:sessionId/prompts', async (req, res) => {
|
|
735
837
|
const sessionId = req.params.sessionId;
|
|
736
838
|
if (!store.get(sessionId)) return res.status(404).json({ error: 'artifact review not found' });
|
|
737
839
|
|
|
738
840
|
const prompts = req.body && req.body.prompts;
|
|
739
841
|
if (!Array.isArray(prompts)) return res.status(400).json({ error: 'prompts must be an array' });
|
|
740
842
|
|
|
843
|
+
// Snapshot whether a poll is in flight BEFORE queuing: queuePrompts emits
|
|
844
|
+
// 'feedback' synchronously, which makes an in-flight poll deliver + tear down
|
|
845
|
+
// (decrementing the count to 0) before we could observe it. Reading the count
|
|
846
|
+
// first is the correct "was the agent waiting when this arrived?" signal.
|
|
847
|
+
const hadActivePoll = (activePolls.get(sessionId) || 0) > 0;
|
|
848
|
+
|
|
741
849
|
const review = store.queuePrompts(sessionId, prompts, req.body ? req.body.domSnapshot : undefined);
|
|
742
|
-
|
|
850
|
+
|
|
851
|
+
// Artifact push (default off; pushToAgent is null unless enabled). If the
|
|
852
|
+
// agent was NOT waiting on a poll, the queued feedback would otherwise sit
|
|
853
|
+
// until the agent next polls. Push it into the CLI as a new turn so an idle
|
|
854
|
+
// agent reacts. When a poll WAS in flight the queue path already delivers it,
|
|
855
|
+
// so we never inject then (injecting mid-turn would race the TUI).
|
|
856
|
+
//
|
|
857
|
+
// We CLAIM the feedback (ackFeedback) synchronously BEFORE the await, so a
|
|
858
|
+
// poll that arrives during the push window sees an empty queue and cannot
|
|
859
|
+
// also deliver the same feedback (no double-delivery). If the push then fails
|
|
860
|
+
// or times out we re-queue exactly what we claimed, so feedback is never lost
|
|
861
|
+
// and the poll path still works. The server hook applies its own PTY-quiet
|
|
862
|
+
// idle check and may decline.
|
|
863
|
+
let pushed = false;
|
|
864
|
+
if (pushToAgent && !hadActivePoll) {
|
|
865
|
+
const snapshot = store.peekFeedback(sessionId);
|
|
866
|
+
const text = feedbackHasData(snapshot) ? formatFeedbackForAgent(snapshot.prompts) : '';
|
|
867
|
+
if (text) {
|
|
868
|
+
store.ackFeedback(sessionId, snapshot); // claim before await
|
|
869
|
+
try {
|
|
870
|
+
pushed = !!(await Promise.race([
|
|
871
|
+
Promise.resolve(pushToAgent(sessionId, text)),
|
|
872
|
+
new Promise((resolve) => setTimeout(() => resolve(false), pushTimeoutMs)),
|
|
873
|
+
]));
|
|
874
|
+
} catch (_) {
|
|
875
|
+
pushed = false;
|
|
876
|
+
}
|
|
877
|
+
if (!pushed) {
|
|
878
|
+
// Re-queue what we claimed (to the FRONT, preserving order) so the poll
|
|
879
|
+
// path still delivers it.
|
|
880
|
+
store.restoreClaimedFeedback(sessionId, snapshot);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
res.json({
|
|
886
|
+
ok: true,
|
|
887
|
+
pushed,
|
|
888
|
+
queued: pushed ? 0 : (review ? review.queuedPrompts.length : 0),
|
|
889
|
+
});
|
|
743
890
|
});
|
|
744
891
|
|
|
745
892
|
router.post('/:sessionId/layout-warnings', (req, res) => {
|
|
@@ -849,11 +996,18 @@ function createArtifactReviewRouter(options) {
|
|
|
849
996
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
850
997
|
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
851
998
|
|
|
999
|
+
// This request is now an in-flight poll; mark the agent as actively waiting
|
|
1000
|
+
// so concurrent /prompts feedback is delivered HERE (not injected into the
|
|
1001
|
+
// PTY). Decremented exactly once on teardown.
|
|
1002
|
+
let pollCounted = true;
|
|
1003
|
+
pollDelta(sessionId, 1);
|
|
1004
|
+
|
|
852
1005
|
let done = false;
|
|
853
1006
|
let snapshotToAck = null;
|
|
854
1007
|
let timeout = null;
|
|
855
1008
|
let heartbeat = null;
|
|
856
1009
|
function cleanup() {
|
|
1010
|
+
if (pollCounted) { pollCounted = false; pollDelta(sessionId, -1); }
|
|
857
1011
|
if (timeout) clearTimeout(timeout);
|
|
858
1012
|
if (heartbeat) clearInterval(heartbeat);
|
|
859
1013
|
store.removeListener('feedback', onFeedback);
|
|
@@ -924,6 +1078,9 @@ module.exports = {
|
|
|
924
1078
|
createAssetTokenSigner,
|
|
925
1079
|
createArtifactReviewRouter,
|
|
926
1080
|
feedbackHasData,
|
|
1081
|
+
formatFeedbackForAgent,
|
|
1082
|
+
buildArtifactPushPayload,
|
|
1083
|
+
artifactPushEnabledFromEnv,
|
|
927
1084
|
injectLavishSdk,
|
|
928
1085
|
isMarkdownFile,
|
|
929
1086
|
markdownArtifactShell,
|
package/src/base-bridge.js
CHANGED
|
@@ -320,6 +320,10 @@ class BaseBridge {
|
|
|
320
320
|
workingDir,
|
|
321
321
|
created: new Date(),
|
|
322
322
|
active: true,
|
|
323
|
+
// Epoch ms of the most recent PTY output, updated in the onData handler.
|
|
324
|
+
// Used by the artifact-push idle gate (msSinceLastOutput) to avoid
|
|
325
|
+
// injecting input while the CLI is actively rendering. Seeded at spawn.
|
|
326
|
+
lastOutputAt: Date.now(),
|
|
323
327
|
killTimeout: null,
|
|
324
328
|
writeQueue: Promise.resolve(),
|
|
325
329
|
// PTY listener handles registered against ptyProcess.{onData,onExit,on('error')}.
|
|
@@ -382,6 +386,7 @@ class BaseBridge {
|
|
|
382
386
|
receivedLifeSign = true;
|
|
383
387
|
clearTimeout(spawnWatchdog);
|
|
384
388
|
}
|
|
389
|
+
session.lastOutputAt = Date.now();
|
|
385
390
|
|
|
386
391
|
if (process.env.DEBUG) {
|
|
387
392
|
console.log(`${this.toolName} session ${sessionId} output:`, data);
|
|
@@ -558,6 +563,19 @@ class BaseBridge {
|
|
|
558
563
|
return session.writeQueue;
|
|
559
564
|
}
|
|
560
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Milliseconds since this session's PTY last emitted output, or null if there
|
|
568
|
+
* is no active session. Used by the artifact-push idle gate to skip injecting
|
|
569
|
+
* input while the CLI is actively rendering (a proxy for "not mid-turn").
|
|
570
|
+
* @param {string} sessionId
|
|
571
|
+
* @returns {number|null}
|
|
572
|
+
*/
|
|
573
|
+
msSinceLastOutput(sessionId) {
|
|
574
|
+
const session = this.sessions.get(sessionId);
|
|
575
|
+
if (!session || !session.active) return null;
|
|
576
|
+
return Date.now() - (session.lastOutputAt || 0);
|
|
577
|
+
}
|
|
578
|
+
|
|
561
579
|
/**
|
|
562
580
|
* Write data to the PTY in chunks to prevent kernel buffer overflow.
|
|
563
581
|
* @private
|
package/src/control/routes.js
CHANGED
|
@@ -187,6 +187,10 @@ function createControlRouter(deps) {
|
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
router.get('/mesh/peers', (req, res) => {
|
|
191
|
+
res.json({ peers: deps.getMeshPeers ? deps.getMeshPeers() : [] });
|
|
192
|
+
});
|
|
193
|
+
|
|
190
194
|
// GET /snapshot — F15 atomic batch resync. Returns every session's derived
|
|
191
195
|
// status PLUS the event cursor, captured atomically (cursor first, then
|
|
192
196
|
// statuses), so after a gap/overflow the controller resyncs in ONE call and
|
package/src/mesh-manager.js
CHANGED
|
@@ -17,28 +17,32 @@ const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
|
|
|
17
17
|
const MIN_RESTART_DELAY_MS = 1000;
|
|
18
18
|
const MAX_RESTART_DELAY_MS = 30000;
|
|
19
19
|
const MAX_RETRIES = 10;
|
|
20
|
+
const MESH_PEERS_JSON_MAX = 256 * 1024;
|
|
20
21
|
|
|
21
22
|
class MeshManager {
|
|
22
23
|
constructor(options = {}) {
|
|
23
24
|
this.port = options.port || 7777; // the ai-or-die port to expose
|
|
24
25
|
this.dev = options.dev || false;
|
|
25
26
|
this.onUrl = options.onUrl || (() => {});
|
|
26
|
-
|
|
27
|
+
this._proxyBearer = options.authToken || null;
|
|
28
|
+
// Consume the key once and scrub secrets everywhere they could leak.
|
|
27
29
|
this._authKey = options.authKey || process.env.AIORDIE_TS_AUTHKEY || null;
|
|
28
30
|
delete process.env.AIORDIE_TS_AUTHKEY;
|
|
31
|
+
delete process.env.AIORDIE_PROXY_BEARER;
|
|
29
32
|
this._childEnv = { ...process.env };
|
|
30
33
|
delete this._childEnv.AIORDIE_TS_AUTHKEY;
|
|
34
|
+
delete this._childEnv.AIORDIE_PROXY_BEARER;
|
|
31
35
|
|
|
32
36
|
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
33
|
-
|
|
37
|
+
this._appBase = process.platform === 'win32'
|
|
34
38
|
? path.join(localApp, 'ai-or-die')
|
|
35
39
|
: path.join(os.homedir(), '.ai-or-die');
|
|
36
|
-
this.stateDir = options.stateDir || path.join(
|
|
40
|
+
this.stateDir = options.stateDir || path.join(this._appBase, 'ts-state');
|
|
37
41
|
// The sidecar binary is content-addressed: its path embeds the lock's
|
|
38
42
|
// contentHash so a new build installs alongside the old one (no overwrite of
|
|
39
43
|
// a running .exe). Derived from the installer's lock + path helpers.
|
|
40
44
|
this._installer = options._installer || require('./utils/sidecar-installer');
|
|
41
|
-
this.sidecar = options.sidecar || this._sidecarPathFromLock(
|
|
45
|
+
this.sidecar = options.sidecar || this._sidecarPathFromLock(this._appBase);
|
|
42
46
|
this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
43
47
|
|
|
44
48
|
this.proc = null;
|
|
@@ -53,6 +57,8 @@ class MeshManager {
|
|
|
53
57
|
this._restartDelayTimer = null;
|
|
54
58
|
this._restartDelayResolve = null;
|
|
55
59
|
this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
|
|
60
|
+
this._stdoutBuffer = '';
|
|
61
|
+
this.peers = null;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Never throws — degrades to localhost/devtunnel on any failure. */
|
|
@@ -93,11 +99,20 @@ class MeshManager {
|
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
getStatus() {
|
|
96
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
running: this.proc !== null && !this.stopping && !!this.dnsName,
|
|
104
|
+
publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null,
|
|
105
|
+
peers: this.peers ? this.peers.peers : [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
peersFilePath() {
|
|
110
|
+
return path.join(this._appBase, 'mesh', 'peers.json');
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
async stop() {
|
|
100
114
|
this.stopping = true;
|
|
115
|
+
this._deletePeersFile();
|
|
101
116
|
this._clearStabilityTimer();
|
|
102
117
|
clearTimeout(this._restartDelayTimer);
|
|
103
118
|
if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
|
|
@@ -117,36 +132,123 @@ class MeshManager {
|
|
|
117
132
|
_spawn() {
|
|
118
133
|
return new Promise((resolve) => {
|
|
119
134
|
const args = ['--port', String(this.port), '--backend', this.backend, '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
120
|
-
// Pass
|
|
121
|
-
//
|
|
122
|
-
const env =
|
|
135
|
+
// Pass secrets only to the sidecar child; both are stripped from this
|
|
136
|
+
// process + base child env before spawning.
|
|
137
|
+
const env = { ...this._childEnv };
|
|
138
|
+
if (this._authKey) env.TS_AUTHKEY = this._authKey;
|
|
139
|
+
if (this._proxyBearer) env.AIORDIE_PROXY_BEARER = this._proxyBearer;
|
|
140
|
+
this._stdoutBuffer = '';
|
|
123
141
|
this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
124
142
|
let done = false;
|
|
125
143
|
const timer = setTimeout(() => { if (!done) { done = true; console.warn(' \x1b[33mMesh: no URL within 60s — check key/connectivity.\x1b[0m'); resolve(); } }, URL_TIMEOUT_MS);
|
|
126
144
|
this.proc.stdout.on('data', (d) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
if (this.dev) process.stdout.write(` [mesh] ${d}`);
|
|
146
|
+
this._handleStdoutData(d, (line) => {
|
|
147
|
+
if (/^MESH-NOCERT\b/.test(line)) this._printNoCert();
|
|
148
|
+
const url = line.match(/^MESH-URL (https?):\/\/(\S+)/);
|
|
149
|
+
if (url && !this.dnsName) {
|
|
150
|
+
this.scheme = url[1];
|
|
151
|
+
this.dnsName = url[2];
|
|
152
|
+
this._authKey = null;
|
|
153
|
+
this._startStabilityTimer();
|
|
154
|
+
this.onUrl(`${this.scheme}://${this.dnsName}`);
|
|
155
|
+
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
156
|
+
}
|
|
157
|
+
if (/^MESH-NEEDLOGIN\b/.test(line)) {
|
|
158
|
+
this._deletePeersFile();
|
|
159
|
+
this._printNotEnrolled();
|
|
160
|
+
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
161
|
+
}
|
|
162
|
+
});
|
|
140
163
|
});
|
|
141
164
|
this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
|
|
142
165
|
this.proc.on('error', (e) => { console.error(` \x1b[31mmesh sidecar failed: ${e.message}\x1b[0m`); this.proc = null; if (!done) { done = true; clearTimeout(timer); resolve(); } });
|
|
143
166
|
this.proc.on('exit', (code) => {
|
|
144
|
-
this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
|
|
167
|
+
this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
|
|
145
168
|
if (!this.stopping && code !== 0) this._restart();
|
|
146
169
|
});
|
|
147
170
|
});
|
|
148
171
|
}
|
|
149
172
|
|
|
173
|
+
_handleStdoutData(chunk, onLine) {
|
|
174
|
+
try {
|
|
175
|
+
this._stdoutBuffer += chunk.toString();
|
|
176
|
+
const lines = this._stdoutBuffer.split('\n');
|
|
177
|
+
this._stdoutBuffer = lines.pop() || '';
|
|
178
|
+
// Bound the carry-over: a newline-less run longer than any legitimate line
|
|
179
|
+
// (MESH-PEERS is capped well under this) is junk/log-spam — drop it so a
|
|
180
|
+
// misbehaving child can't grow the buffer without limit.
|
|
181
|
+
if (this._stdoutBuffer.length > MESH_PEERS_JSON_MAX) this._stdoutBuffer = '';
|
|
182
|
+
for (const rawLine of lines) {
|
|
183
|
+
const line = rawLine.replace(/\r$/, '');
|
|
184
|
+
this._handleStdoutLine(line);
|
|
185
|
+
if (onLine) onLine(line);
|
|
186
|
+
}
|
|
187
|
+
} catch (_) {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_handleStdoutLine(line) {
|
|
191
|
+
try {
|
|
192
|
+
if (/^MESH-NEEDLOGIN\b/.test(line)) this._deletePeersFile();
|
|
193
|
+
const match = /^MESH-PEERS\s+(.+)$/.exec(line);
|
|
194
|
+
if (!match) return;
|
|
195
|
+
const raw = match[1];
|
|
196
|
+
if (Buffer.byteLength(raw, 'utf8') > MESH_PEERS_JSON_MAX) {
|
|
197
|
+
if (this.dev) console.debug(' [mesh] ignoring oversized MESH-PEERS payload');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(raw);
|
|
203
|
+
} catch (_) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const normalized = this._validatePeersPayload(parsed);
|
|
207
|
+
if (!normalized) return;
|
|
208
|
+
this.peers = normalized;
|
|
209
|
+
this._writePeersFile(normalized);
|
|
210
|
+
} catch (_) {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_validatePeersPayload(payload) {
|
|
214
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
|
215
|
+
const self = payload.self;
|
|
216
|
+
if (!self || typeof self !== 'object' || Array.isArray(self)) return null;
|
|
217
|
+
if (typeof self.hostname !== 'string' || typeof self.dnsName !== 'string') return null;
|
|
218
|
+
if (!Array.isArray(payload.peers)) return null;
|
|
219
|
+
const peers = [];
|
|
220
|
+
for (const p of payload.peers) {
|
|
221
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) continue;
|
|
222
|
+
if (typeof p.hostname !== 'string' || typeof p.dnsName !== 'string' || typeof p.online !== 'boolean') continue;
|
|
223
|
+
peers.push({ hostname: p.hostname, dnsName: p.dnsName, online: p.online });
|
|
224
|
+
}
|
|
225
|
+
return { self: { hostname: self.hostname, dnsName: self.dnsName }, peers };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_writePeersFile(snapshot) {
|
|
229
|
+
const file = this.peersFilePath();
|
|
230
|
+
const dir = path.dirname(file);
|
|
231
|
+
const tmp = path.join(dir, `peers.json.${process.pid}.tmp`);
|
|
232
|
+
try {
|
|
233
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
234
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
235
|
+
version: 1,
|
|
236
|
+
updatedAt: Date.now(),
|
|
237
|
+
self: snapshot.self,
|
|
238
|
+
peers: snapshot.peers,
|
|
239
|
+
}), { mode: 0o600 });
|
|
240
|
+
fs.chmodSync(tmp, 0o600);
|
|
241
|
+
fs.renameSync(tmp, file);
|
|
242
|
+
} catch (_) {
|
|
243
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_deletePeersFile() {
|
|
248
|
+
this.peers = null;
|
|
249
|
+
try { fs.rmSync(this.peersFilePath(), { force: true }); } catch (_) {}
|
|
250
|
+
}
|
|
251
|
+
|
|
150
252
|
_printMissing(err) {
|
|
151
253
|
const code = err && err.code;
|
|
152
254
|
const map = {
|
|
@@ -212,3 +212,60 @@
|
|
|
212
212
|
linear-gradient(135deg, transparent 0 50%, var(--border-hover, #484f58) 50% 60%, transparent 60% 70%, var(--border-hover, #484f58) 70% 80%, transparent 80%);
|
|
213
213
|
}
|
|
214
214
|
.artifact-panel__resize[hidden] { display: none; }
|
|
215
|
+
|
|
216
|
+
/* Maximized layout: horizontal split. Artifact content is the large flexible
|
|
217
|
+
pane; chat is a fixed 360px right rail (the lavish-axi / mainstream artifact-UI
|
|
218
|
+
model). Maximizing is a request for content WIDTH, so spend the reclaimed
|
|
219
|
+
space on the artifact, not on a wide chat. CSS grid (not a JS reparent) places
|
|
220
|
+
pillbar / chat / chatbar into the rail in source order: pills at the rail top,
|
|
221
|
+
chat filling the middle, the composer at the bottom. Reverts to the default
|
|
222
|
+
vertical stack under 1024px, where a narrow window reads better stacked. */
|
|
223
|
+
.artifact-panel--maximized .artifact-panel__body {
|
|
224
|
+
display: grid;
|
|
225
|
+
grid-template-columns: minmax(0, 1fr) 360px;
|
|
226
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
227
|
+
}
|
|
228
|
+
.artifact-panel--maximized .artifact-panel__frame {
|
|
229
|
+
grid-column: 1;
|
|
230
|
+
grid-row: 1 / 4; /* content spans the full height on the left */
|
|
231
|
+
/* The frame is an <iframe> (a replaced element): it uses its intrinsic size and
|
|
232
|
+
does NOT stretch to fill a grid cell like a normal block, so it needs explicit
|
|
233
|
+
100% dimensions (the old flex layout filled it via `flex: 1`). */
|
|
234
|
+
width: 100%;
|
|
235
|
+
height: 100%;
|
|
236
|
+
}
|
|
237
|
+
.artifact-panel--maximized .artifact-panel__pillbar {
|
|
238
|
+
grid-column: 2;
|
|
239
|
+
grid-row: 1;
|
|
240
|
+
border-left: 1px solid var(--panel-border, #33363f);
|
|
241
|
+
}
|
|
242
|
+
.artifact-panel--maximized .artifact-panel__chat {
|
|
243
|
+
grid-column: 2;
|
|
244
|
+
grid-row: 2;
|
|
245
|
+
max-height: none; /* fill the rail middle row instead of the 30% cap */
|
|
246
|
+
border-left: 1px solid var(--panel-border, #33363f);
|
|
247
|
+
}
|
|
248
|
+
.artifact-panel--maximized .artifact-panel__chatbar {
|
|
249
|
+
grid-column: 2;
|
|
250
|
+
grid-row: 3;
|
|
251
|
+
border-left: 1px solid var(--panel-border, #33363f);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* Narrow viewport: revert the maximized panel to the vertical stack so the
|
|
255
|
+
content is not squeezed into a sliver beside a 360px rail. */
|
|
256
|
+
@media (max-width: 1024px) {
|
|
257
|
+
.artifact-panel--maximized .artifact-panel__body {
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
}
|
|
261
|
+
.artifact-panel--maximized .artifact-panel__frame,
|
|
262
|
+
.artifact-panel--maximized .artifact-panel__pillbar,
|
|
263
|
+
.artifact-panel--maximized .artifact-panel__chat,
|
|
264
|
+
.artifact-panel--maximized .artifact-panel__chatbar {
|
|
265
|
+
grid-column: auto;
|
|
266
|
+
grid-row: auto;
|
|
267
|
+
border-left: none;
|
|
268
|
+
}
|
|
269
|
+
.artifact-panel--maximized .artifact-panel__frame { width: 100%; }
|
|
270
|
+
.artifact-panel--maximized .artifact-panel__chat { max-height: 30%; }
|
|
271
|
+
}
|
package/src/server.js
CHANGED
|
@@ -37,7 +37,7 @@ const KeepaliveManager = require('./keepalive-manager');
|
|
|
37
37
|
const { ControlEventBus, EVENT_KINDS: CONTROL_EVENT_KINDS } = require('./control/event-bus');
|
|
38
38
|
const TranscriptBuffer = require('./sticky-note-transcript');
|
|
39
39
|
const { createControlRouter } = require('./control/routes');
|
|
40
|
-
const { ArtifactReviewStore, createArtifactReviewRouter, createAssetTokenSigner } = require('./artifact-review');
|
|
40
|
+
const { ArtifactReviewStore, createArtifactReviewRouter, createAssetTokenSigner, buildArtifactPushPayload, artifactPushEnabledFromEnv } = require('./artifact-review');
|
|
41
41
|
const { deriveStatus, awaitingKindForPendingTool, awaitingFromScreen, TRUST_PROMPT_REGEX, DEFAULT_UNBOUND_QUIET_MS } = require('./control/session-status');
|
|
42
42
|
const { detectAwaiting } = require('./control/jsonl-awaiting');
|
|
43
43
|
|
|
@@ -166,6 +166,14 @@ class ClaudeCodeWebServer {
|
|
|
166
166
|
this.artifactPollHoldMs = options.artifactPollHoldMs || 25000;
|
|
167
167
|
this.artifactPollHeartbeatMs = options.artifactPollHeartbeatMs || 5000;
|
|
168
168
|
this.artifactSseHeartbeatMs = options.artifactSseHeartbeatMs || 15000;
|
|
169
|
+
// Artifact push (default ON): panel feedback that arrives while the agent is
|
|
170
|
+
// idle (no in-flight poll + PTY quiet) is injected into the CLI as a new turn,
|
|
171
|
+
// so the composer works without the human switching to the terminal or the
|
|
172
|
+
// agent having to poll. Opt OUT with AIORDIE_ARTIFACT_PUSH=0 (or false/off/no).
|
|
173
|
+
// The idle-gate + residual TUI-timing risk are recorded in docs/adrs/0035.
|
|
174
|
+
this._artifactPushEnabled = artifactPushEnabledFromEnv(process.env.AIORDIE_ARTIFACT_PUSH);
|
|
175
|
+
const quietRaw = Number(process.env.AIORDIE_ARTIFACT_PUSH_QUIET_MS);
|
|
176
|
+
this._artifactPushQuietMs = Number.isFinite(quietRaw) && quietRaw > 0 ? quietRaw : 1500;
|
|
169
177
|
// PROC-04: min-heap of {id, lastActivity} pairs keyed by lastActivity.
|
|
170
178
|
// Used by _evictStaleSessions to find the oldest session in O(log n)
|
|
171
179
|
// rather than scanning the full Map every 5 min. Lazy-tombstone protocol
|
|
@@ -1270,6 +1278,9 @@ class ClaudeCodeWebServer {
|
|
|
1270
1278
|
pollHoldMs: this.artifactPollHoldMs,
|
|
1271
1279
|
pollHeartbeatMs: this.artifactPollHeartbeatMs,
|
|
1272
1280
|
sseHeartbeatMs: this.artifactSseHeartbeatMs,
|
|
1281
|
+
pushToAgent: this._artifactPushEnabled
|
|
1282
|
+
? (sessionId, text) => this._pushArtifactFeedbackToAgent(sessionId, text)
|
|
1283
|
+
: null,
|
|
1273
1284
|
}));
|
|
1274
1285
|
|
|
1275
1286
|
// Commands API removed
|
|
@@ -4101,10 +4112,53 @@ class ClaudeCodeWebServer {
|
|
|
4101
4112
|
return bridges[agentType] || null;
|
|
4102
4113
|
}
|
|
4103
4114
|
|
|
4115
|
+
// Return the bridge that currently owns a live PTY for `sessionId`, or null.
|
|
4116
|
+
// Uses msSinceLastOutput (null when the session is absent/inactive) so we
|
|
4117
|
+
// don't reach into a bridge's private session map.
|
|
4118
|
+
_bridgeForSession(sessionId) {
|
|
4119
|
+
const bridges = [
|
|
4120
|
+
this.claudeBridge, this.terminalBridge,
|
|
4121
|
+
this.codexBridge, this.copilotBridge, this.geminiBridge,
|
|
4122
|
+
];
|
|
4123
|
+
for (const bridge of bridges) {
|
|
4124
|
+
if (bridge && typeof bridge.msSinceLastOutput === 'function'
|
|
4125
|
+
&& bridge.msSinceLastOutput(sessionId) !== null) {
|
|
4126
|
+
return bridge;
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
return null;
|
|
4130
|
+
}
|
|
4131
|
+
|
|
4132
|
+
// Artifact-push hook (wired only when AIORDIE_ARTIFACT_PUSH is enabled). Inject
|
|
4133
|
+
// panel feedback into the idle CLI as a NEW turn. Returns true only if it
|
|
4134
|
+
// actually wrote to the PTY (the caller then consumes the queued prompts).
|
|
4135
|
+
// Idle gate: decline when the session's PTY emitted output within the quiet
|
|
4136
|
+
// window (likely mid-render / mid-turn), a heuristic, hence opt-in. Bracketed
|
|
4137
|
+
// paste keeps multi-line feedback atomic; a trailing CR submits the turn.
|
|
4138
|
+
async _pushArtifactFeedbackToAgent(sessionId, text) {
|
|
4139
|
+
if (!text || typeof text !== 'string') return false;
|
|
4140
|
+
const bridge = this._bridgeForSession(sessionId);
|
|
4141
|
+
if (!bridge) return false;
|
|
4142
|
+
const quietMs = bridge.msSinceLastOutput(sessionId);
|
|
4143
|
+
if (quietMs === null || quietMs < this._artifactPushQuietMs) return false;
|
|
4144
|
+
// Sanitize + bracketed-paste-wrap the human text (pure helper, unit-tested in
|
|
4145
|
+
// artifact-review): strips ESC/control bytes so nothing can break out of the
|
|
4146
|
+
// paste envelope, and a trailing CR submits the turn.
|
|
4147
|
+
const payload = buildArtifactPushPayload(text);
|
|
4148
|
+
if (!payload) return false;
|
|
4149
|
+
try {
|
|
4150
|
+
await bridge.sendInput(sessionId, payload);
|
|
4151
|
+
return true;
|
|
4152
|
+
} catch (_) {
|
|
4153
|
+
return false;
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4104
4157
|
_buildControlDeps() {
|
|
4105
4158
|
return {
|
|
4106
4159
|
sessions: this.claudeSessions,
|
|
4107
4160
|
eventBus: this.controlEventBus,
|
|
4161
|
+
getMeshPeers: () => this.meshManager ? this.meshManager.getStatus().peers : [],
|
|
4108
4162
|
getStatusSignal: (id) => this._controlStatusSignal(id),
|
|
4109
4163
|
readTail: async (id, lines) => this._controlReadTail(id, lines),
|
|
4110
4164
|
createSession: async (opts) => this._controlCreateSession(opts),
|