ai-or-die 0.1.89 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.89",
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
@@ -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,6 +4112,48 @@ 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,