@haystackeditor/cli 0.15.0 → 0.15.1

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.
@@ -131,22 +131,32 @@ class JsonPrompter {
131
131
  try {
132
132
  obj = JSON.parse(trimmed);
133
133
  }
134
- catch {
135
- return; // ignore non-JSON noise on stdin
134
+ catch (err) {
135
+ // Surface malformed input on stderr (PR001: no silent swallow) so an
136
+ // agent can tell its line was rejected and resend, instead of the
137
+ // prompt hanging on input that never parsed. stderr keeps stdout NDJSON.
138
+ process.stderr.write(`haystack setup: ignoring unparseable stdin line: ${err instanceof Error ? err.message : String(err)}\n`);
139
+ return;
136
140
  }
137
- if (!obj || typeof obj.id !== 'string')
141
+ if (!obj || typeof obj !== 'object')
142
+ return;
143
+ // Mirror OpenCode: question replies are keyed by `requestID`, permission
144
+ // replies by `permissionID`. Legacy `id` accepted too.
145
+ const key = (obj.requestID ?? obj.permissionID ?? obj.id);
146
+ if (typeof key !== 'string')
138
147
  return;
139
- const resolve = this.pending.get(obj.id);
148
+ const resolve = this.pending.get(key);
140
149
  if (resolve) {
141
- this.pending.delete(obj.id);
142
- resolve(obj.value);
150
+ this.pending.delete(key);
151
+ resolve(obj);
143
152
  }
144
153
  else {
145
- this.buffered.set(obj.id, obj.value);
154
+ this.buffered.set(key, obj);
146
155
  }
147
156
  });
148
157
  }
149
- awaitAnswer(id) {
158
+ /** Wait for the agent's reply object, keyed by the request/permission id. */
159
+ awaitReply(id) {
150
160
  if (this.buffered.has(id)) {
151
161
  const v = this.buffered.get(id);
152
162
  this.buffered.delete(id);
@@ -158,8 +168,14 @@ class JsonPrompter {
158
168
  async confirm(q) {
159
169
  if (q.id in this.answers)
160
170
  return Boolean(this.answers[q.id]);
161
- this.emit({ type: 'question', id: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
162
- return Boolean(await this.awaitAnswer(q.id));
171
+ // A confirm is a single-choice question; emit OpenCode's `question` envelope.
172
+ // Reply: {requestID, answers: <bool>}; reject: {requestID, reject: true}.
173
+ this.emit({ type: 'question', requestID: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
174
+ const reply = await this.awaitReply(q.id);
175
+ if (reply.reject)
176
+ return false;
177
+ const a = reply.answers ?? reply.value; // `answers` is canonical; `value` accepted
178
+ return typeof a === 'boolean' ? a : Boolean(a);
163
179
  }
164
180
  async multiselect(q) {
165
181
  if (q.id in this.answers)
@@ -167,33 +183,45 @@ class JsonPrompter {
167
183
  const options = q.choices
168
184
  .filter((c) => !isSeparator(c))
169
185
  .map((c) => ({ value: c.value, label: c.name, default: c.checked ?? false }));
170
- this.emit({ type: 'question', id: q.id, kind: 'multiselect', prompt: q.message, options });
171
- return (await this.awaitAnswer(q.id));
186
+ // Reply: {requestID, answers: <value[]>}; reject: {requestID, reject: true}.
187
+ this.emit({ type: 'question', requestID: q.id, kind: 'multiselect', prompt: q.message, options });
188
+ const reply = await this.awaitReply(q.id);
189
+ if (reply.reject)
190
+ return [];
191
+ const a = reply.answers ?? reply.value;
192
+ return Array.isArray(a) ? a : [];
172
193
  }
173
194
  async action(q) {
174
195
  if (this.answers[q.id] === 'skip')
175
196
  return false;
176
197
  if (await q.check())
177
198
  return true;
178
- // Tell the agent an out-of-band action is needed; then poll until satisfied.
179
- // An agent may also push {"id","value":"skip"|"done"} on stdin to override.
180
- this.emit({ type: 'action', id: q.id, prompt: q.message, url: q.url, poll: true });
199
+ // An out-of-band action maps to OpenCode's `permission` request. Reply:
200
+ // {permissionID, response: "once"|"always"|"reject"}. We also keep polling
201
+ // check() so it resolves on its own once the action completes.
202
+ this.emit({ type: 'permission', permissionID: q.id, prompt: q.message, url: q.url, poll: true });
181
203
  const started = Date.now();
182
204
  const timeout = q.timeoutMs ?? 10 * 60 * 1000;
183
205
  const interval = q.pollIntervalMs ?? 3000;
184
206
  let override = null;
185
- // Resolve the moment the agent answers, OR when polling detects completion.
186
207
  this.ensureStdin();
187
- const answerPromise = this.awaitAnswer(q.id).then((v) => {
188
- override = v === 'skip' ? 'skip' : 'done';
208
+ const answerPromise = this.awaitReply(q.id).then((reply) => {
209
+ const r = (reply.response ?? reply.value);
210
+ override = r === 'reject' || r === 'skip' ? 'skip' : 'done';
189
211
  });
190
212
  while (Date.now() - started < timeout) {
191
213
  if (await q.check())
192
214
  return true;
193
215
  if (override === 'skip')
194
216
  return false;
195
- if (override === 'done')
196
- return (await q.check()); // agent says installed; trust but verify
217
+ // 'done' means the agent reports the action complete — but completion may
218
+ // be eventually-consistent, so keep polling check() at the interval until
219
+ // it confirms or we time out (matching TTY behavior), rather than failing
220
+ // on a single immediate check.
221
+ if (override === 'done') {
222
+ await sleep(interval);
223
+ continue;
224
+ }
197
225
  await Promise.race([sleep(interval), answerPromise]);
198
226
  }
199
227
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {