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 +1 -1
- package/src/artifact-review.js +159 -2
- package/src/base-bridge.js +18 -0
- package/src/public/components/artifact-panel.css +57 -0
- package/src/server.js +54 -1
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
|
|
@@ -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,
|