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 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 certWaitTimeout = 20 * time.Second
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, and the
71
- // pointer-captured proto must reflect the post-TLS decision.
72
- func TestEdgeProxyForwardsProtoAndHost(t *testing.T) {
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
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "version": "1.0.0",
3
- "contentHash": "fb010e2cd6a930ef7eeac0de0adaf903cdb7252815650c38eac2d72f15da6e3d",
3
+ "contentHash": "dbf60b5215ebb0b0947edf8fa47906280dd1fea12ef7e1181918fecfe7d9bcc0",
4
4
  "assets": {
5
- "aiordie-mesh-windows-amd64.exe": "7a30a635b726e5cae694e3f48b37b329d470e8cf493ac5f025b99ea4a0fbded0",
6
- "aiordie-mesh-windows-arm64.exe": "6d056c561538a40991a975e410b61b7ba4e076c456ee280744ae9ade65202dda",
7
- "aiordie-mesh-linux-amd64": "2e5fd52842b67ec56647efb59ce8944bb211055b7630a19206a67b2c80b328fd",
8
- "aiordie-mesh-linux-arm64": "7508a98a2762feb3405df54150a34c53028b5588b44eac504835c56a5df82822",
9
- "aiordie-mesh-darwin-amd64": "d095c85d01036f3a9a529179c9ef5ca67231150bd49fc5adf1c4fb4e0d0da11b",
10
- "aiordie-mesh-darwin-arm64": "8d115b8233697995dc0d7bab8f45f881bba2d37896c86cd70ea626965b1e4cb0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -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
- res.json({ ok: true, queued: review ? review.queuedPrompts.length : 0 });
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,
@@ -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
@@ -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
@@ -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
- // Consume the key once and scrub it everywhere it could leak.
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
- const base = process.platform === 'win32'
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(base, 'ts-state');
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(base);
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 { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null };
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 the key to the sidecar via TS_AUTHKEY (enroll only); it's already
121
- // stripped from this process + base child env, so it reaches only this child.
122
- const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
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
- const out = d.toString();
128
- if (this.dev) process.stdout.write(` [mesh] ${out}`);
129
- if (/MESH-NOCERT/.test(out)) this._printNoCert();
130
- const url = out.match(/MESH-URL (https?):\/\/(\S+)/);
131
- if (url && !this.dnsName) {
132
- this.scheme = url[1];
133
- this.dnsName = url[2];
134
- this._authKey = null;
135
- this._startStabilityTimer();
136
- this.onUrl(`${this.scheme}://${this.dnsName}`);
137
- if (!done) { done = true; clearTimeout(timer); resolve(); }
138
- }
139
- if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
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),