hivecode-mcp 0.14.2 → 0.14.3

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.
@@ -68,7 +68,7 @@ export function saveRoomState(file, s) {
68
68
  export function makeRoomWatcher(o) {
69
69
  const {
70
70
  gh, link, branch, label = LABEL, stateFile, deps,
71
- ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2,
71
+ ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2, refusalRetryMinutes = 10,
72
72
  fallbackAgent = '', host = os.hostname(),
73
73
  now = () => Date.now(), log = () => {},
74
74
  } = o
@@ -100,10 +100,23 @@ export function makeRoomWatcher(o) {
100
100
  .split(roomId).join('[room id removed]')
101
101
  .replace(/wss?:\/\/[^\s|)'"`]+(\|[^\s)'"`]*)?/g, '[relay link removed]')
102
102
  .replace(/\broom-[A-Za-z0-9_-]{12,}/g, '[room id removed]')
103
+ // LOCAL PATHS: a refusal's "why" names files on the watcher's machine (a home folder, a temp
104
+ // checkout); the issue is public (0.14.3 P0-2, Claude's review). Not inside URLs.
105
+ .replace(/(?<![\w.:/\\-])(?:[A-Za-z]:[\\/]|\\\\|\/(?:home|Users|tmp|var|root|private|opt|mnt|srv|workspace|data)\/)[^\s'"`)\]]*/g, '[local path removed]')
103
106
  const comment = (n, body) => { try { gh.comment(n, scrubRoom(body)) } catch (e) { log(`#${n}: could not comment on GitHub: ${e.message}`) } }
104
107
  const today = () => new Date(now()).toISOString().slice(0, 10)
105
108
  const runsToday = () => { if (state.day.date !== today()) state.day = { date: today(), runs: 0 }; return state.day.runs }
106
109
 
110
+ // A room line that may not arrive: deps.say is roomSay, confirmed by a second client. When it is
111
+ // not confirmed (or throws) the watcher logs it and carries on; the caller has already put the
112
+ // same news on GitHub, so nobody is left with neither (0.14.3 P0-2).
113
+ async function sayChecked(n, text) {
114
+ let r = null
115
+ try { r = await deps.say(text) } catch (e) { r = { ok: false, why: e && e.message } }
116
+ if (!r || !r.ok) log(`#${n}: could not confirm the room heard it (${(r && (r.why || r.code)) || 'no answer'}); the GitHub comment stands`)
117
+ return r
118
+ }
119
+
107
120
  function needsHuman(n, why, lines = []) {
108
121
  const it = state.issues[n]
109
122
  if (it) it.status = 'needs-human'
@@ -197,9 +210,15 @@ export function makeRoomWatcher(o) {
197
210
  const solo = (a.agentsSeen || []).length <= 1
198
211
  const graceMs = (solo ? soloGraceMinutes : ackMinutes) * 60000
199
212
  const allowUnreviewed = !!(a.unreviewedSince && now() - a.unreviewedSince >= graceMs && peek && peek.ok && peek.reachable.length <= 1)
200
- const r = await deps.landIssue({ taskId: a.taskId, issue: { number: n, title: it.title }, lastFailedFingerprint: a.lastFailedFingerprint || '', allowUnreviewed })
213
+ // A REFUSAL IS TRIED AGAIN AFTER A PAUSE even when nothing changed (a push answered 503, a lock
214
+ // on the remote): once per refusalRetryMinutes, never every tick, and a repeat of the same
215
+ // refusal is neither said again nor counted again (antigravity-VM's attack 1). Failing tests
216
+ // are not retried this way: they wait for the room to change.
217
+ let lastFp = a.lastFailedFingerprint || ''
218
+ if (lastFp && a.lastRefusal && now() - (a.refusalTriedAt || a.lastRefusal.at) >= refusalRetryMinutes * 60000) { lastFp = ''; a.refusalTriedAt = now() }
219
+ const r = await deps.landIssue({ taskId: a.taskId, issue: { number: n, title: it.title }, lastFailedFingerprint: lastFp, allowUnreviewed })
201
220
  if (r && !r.ok && r.blocked === 'unreviewed') {
202
- if (!a.unreviewedSince) {
221
+ if (!a.unreviewedSince && !a.unreviewedSaid) {
203
222
  a.unreviewedSince = now()
204
223
  const files = (r.files || []).slice(0, 6).join(', ') || 'the changed files'
205
224
  if (solo) {
@@ -208,8 +227,16 @@ export function makeRoomWatcher(o) {
208
227
  } else {
209
228
  await deps.say(`@all issue #${n} is done and waiting for a review of ${files} by someone who did not write them (hive_review). Nothing lands until then.`)
210
229
  }
230
+ a.unreviewedSaid = true
211
231
  }
212
- } else if (r && !r.ok) a.unreviewedSince = 0
232
+ if (!a.unreviewedSince) a.unreviewedSince = now()
233
+ } else if (r && !r.ok && !r.skipped && !r.code && !r.fingerprint) {
234
+ // Only "not ready" (work reopened, a claim taken) restarts the review wait. A refusal or a
235
+ // skipped landing is not a change in the work: resetting here made a refused solo landing
236
+ // re-announce "nobody else to review" every grace period (antigravity-VM's attack 3).
237
+ a.unreviewedSince = 0
238
+ a.unreviewedSaid = false
239
+ }
213
240
  if (r && r.state && 'lastFailedFingerprint' in r.state) a.lastFailedFingerprint = r.state.lastFailedFingerprint || ''
214
241
  if (r && r.ok && a.kind === 'review') {
215
242
  const pr = r.pr || {}
@@ -234,8 +261,38 @@ export function makeRoomWatcher(o) {
234
261
  }
235
262
  if (r && r.code === 'Q576' && !r.skipped) {
236
263
  a.runs = (a.runs || 0) + 1
264
+ a.lastRefusal = null // failing tests wait for the room to change; they are not retried on a timer
237
265
  if (a.runs >= maxRuns) return needsHuman(n, `the tests failed ${a.runs} times in a row (the limit is ${maxRuns}).`, (peek && peek.lastLines) || [])
238
266
  comment(n, `The tests failed on the agents' work, so nothing was pushed. It went back to the room (attempt ${a.runs} of ${maxRuns}).\n\n${MARK('room', { kind: 'tests-failed', run: a.runs })}`)
267
+ // SAID BY THE WATCHER, CONFIRMED: landIssue's own room line went through a connection that
268
+ // whenDone had already stopped, so the room never heard its tests failed (0.14.3 P0-2).
269
+ await sayChecked(n, `@all the tests failed when landing issue #${n} (attempt ${a.runs} of ${maxRuns}): ${oneLine(r.why, 300)} Nothing was pushed. Fix them in the room; the landing is tried again when the room's files or the branch change.`)
270
+ }
271
+ // A LANDING REFUSED FOR ANY OTHER REASON IS SAID, never swallowed (0.14.3 P0-2). On 0.14.2 a
272
+ // refusal (Q506, a push refused, no git identity...) matched no branch here: nothing in the
273
+ // room, nothing on the issue, and the next tick's identical fingerprint was a silent skip, so
274
+ // issue #9 sat "done" and landed nothing with no word anywhere. Said once per refusal (same
275
+ // code, same fingerprint), counted like failing tests, then a person. The issue comment goes
276
+ // FIRST: an unreachable room must never mean nobody heard (Claude's review).
277
+ if (r && !r.ok && !r.skipped && r.blocked !== 'unreviewed' && r.code !== 'Q576' && (r.code || r.fingerprint)) {
278
+ const fp = r.fingerprint || (r.state && r.state.lastFailedFingerprint) || ''
279
+ const key = `${r.code || ''}|${fp || oneLine(r.why, 120)}`
280
+ if (a.refusedKey !== key) {
281
+ a.refusedKey = key
282
+ a.refusals = (a.refusals || 0) + 1
283
+ const code = r.code || 'refused'
284
+ const why = oneLine(r.why, 300) || 'no reason given'
285
+ a.lastRefusal = { code, why, at: now() }
286
+ log(`#${n}: landing refused (${code}): ${why}`)
287
+ if (a.refusals >= maxRuns) return needsHuman(n, `the landing was refused ${a.refusals} times (the limit is ${maxRuns}); the last time ${code}: ${why}`, (peek && peek.lastLines) || [])
288
+ comment(a.kind === 'review' ? a.pr : n, `The agents' work is done, but Hivecode refused to land it (${code}): ${oneLine(r.why, 400) || 'no reason given'} Nothing was pushed. It went back to the room (refusal ${a.refusals} of ${maxRuns}); it is tried again when the room's files or the branch change.\n\n${MARK('room', { kind: 'refused', code })}`)
289
+ await sayChecked(n, `@all issue #${n} is done, but its landing was REFUSED (${code}): ${why} Nothing was pushed. Fix the cause in the room; the landing is tried again when the room's files or the branch change.`)
290
+ } else if (a.lastRefusal) {
291
+ // The same refusal again (a retry after the pause): not said or counted again, but
292
+ // `github status` shows it is still happening, so a silent wait is never mistaken for
293
+ // progress (Claude's review).
294
+ a.lastRefusal.seen = now()
295
+ }
239
296
  }
240
297
  if (now() - a.postedAt > maxMinutes * 60000) return needsHuman(n, `the room worked on it for more than ${maxMinutes} minutes without a landing.`, (peek && peek.lastLines) || [])
241
298
 
@@ -395,6 +452,10 @@ export function describeRoomState(s, { now = Date.now(), maxRunsPerDay = (s && s
395
452
  out.push(` working on ${what} for ${mins(a.postedAt)} min; ${a.engagedAt ? 'an agent has taken it' : 'NO agent has taken it yet'}${a.runs ? `; tests failed ${a.runs} time(s)` : ''}`)
396
453
  if (a.fallback) out.push(` fallback agent started ${mins(a.fallback.at)} min ago (${a.fallback.why})`)
397
454
  if (a.unreviewedSince) out.push(` done, waiting ${mins(a.unreviewedSince)} min for a review by an agent that did not write it`)
455
+ if (a.lastRefusal) {
456
+ const still = a.lastRefusal.seen ? `; still refused (same reason) for ${mins(a.lastRefusal.at)} min, last tried ${mins(a.lastRefusal.seen)} min ago` : ''
457
+ out.push(` landing refused ${mins(a.lastRefusal.at)} min ago (${a.lastRefusal.code}): ${oneLine(a.lastRefusal.why, 200)}${still}`)
458
+ }
398
459
  } else if (s.openPr) {
399
460
  out.push(` waiting for PR #${s.openPr} to be merged or closed before the next issue (one open PR per room)`)
400
461
  } else {
package/hive-slack.js CHANGED
@@ -180,6 +180,28 @@ export function makeQueue({
180
180
 
181
181
  export const SLACK_TIMEOUT_MS = 15000
182
182
 
183
+ // HOW SLACK READS A CALL'S ARGUMENTS (0.14.3 P0-4). Read methods take their arguments only from the
184
+ // query string or a form body: a JSON body is ignored, so on 0.14.2 conversations.info answered
185
+ // invalid_arguments and users.info user_not_found in a real workspace (measured 9 Nov 2026), which
186
+ // broke `slack check`, the --slack-channel preflight, inbound stop/retry and stop verification.
187
+ // Every fake Slack in the tests accepted JSON, so all of it was green. Write methods (chat.*,
188
+ // agents.*, reactions.*...) take JSON and stay JSON: their blocks and metadata are nested objects.
189
+ const FORM_METHOD = /^(?:(?:conversations|users|team|bots)\.[A-Za-z.]+|auth\.test)$/
190
+ export function slackEncoding(method) {
191
+ return FORM_METHOD.test(String(method || '')) ? 'form' : 'json'
192
+ }
193
+
194
+ /** A form body Slack reads: undefined and null are LEFT OUT (never the text "undefined", which Slack
195
+ * would read as a real channel or user id); a nested value is JSON inside its field, as Slack takes it. */
196
+ export function slackForm(body = {}) {
197
+ const p = new URLSearchParams()
198
+ for (const [k, v] of Object.entries(body || {})) {
199
+ if (v === undefined || v === null) continue
200
+ p.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
201
+ }
202
+ return p.toString()
203
+ }
204
+
183
205
  /** POST to Slack's Web API. Kept tiny and injectable so the bridge can be tested without a
184
206
  * workspace -- the thing worth testing is our routing, not Slack's HTTP.
185
207
  * Applies a 15s AbortSignal timeout by default so a hung TCP socket never stalls the queue. */
@@ -191,10 +213,14 @@ export async function slackPost(
191
213
  apiBase = process.env.HIVE_SLACK_API || DEFAULT_API,
192
214
  timeoutMs = Number(process.env.HIVE_SLACK_TIMEOUT_MS) || SLACK_TIMEOUT_MS,
193
215
  ) {
216
+ const form = slackEncoding(method) === 'form'
194
217
  const r = await fetchImpl(`${apiBase}/${method}`, {
195
218
  method: 'POST',
196
- headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Bearer ${token}` },
197
- body: JSON.stringify(body),
219
+ headers: {
220
+ 'Content-Type': form ? 'application/x-www-form-urlencoded; charset=utf-8' : 'application/json; charset=utf-8',
221
+ Authorization: `Bearer ${token}`,
222
+ },
223
+ body: form ? slackForm(body) : JSON.stringify(body),
198
224
  signal: AbortSignal.timeout(timeoutMs),
199
225
  })
200
226
  const retryHeader = r.headers && typeof r.headers.get === 'function' ? Number(r.headers.get('retry-after') || 0) : 0
@@ -2120,6 +2146,13 @@ export async function runSlackCheck({
2120
2146
  clearTimeout(waitTimer)
2121
2147
 
2122
2148
  if (!arrived) {
2149
+ // Over HTTPS polling nothing is subscribed: the bridge reads the thread itself, and its read
2150
+ // scopes were checked above. So no reply means nobody replied in time, and sending the person
2151
+ // off to reinstall the app was wrong (9 Nov, the first real check: 0 replies in the thread).
2152
+ if (pollInbound) {
2153
+ logErr(`Q634 no reply arrived in the thread within ${Math.round(timeoutMs / 1000)} s. Replies are read over HTTPS, which needs no event subscription, and the read scopes are in place: reply anything in the thread of "Hivecode check: reply anything in this thread" in #${channelName} while the check waits (raise --timeout for more time).`)
2154
+ return 1
2155
+ }
2123
2156
  const manifestName = agent ? 'slack-app-manifest-agent.json' : 'slack-app-manifest-inbound.json'
2124
2157
  logErr(`Q634 replies did not arrive. Event subscriptions are probably missing: reinstall from ${manifestName}`)
2125
2158
  return 1
package/hive-transport.js CHANGED
@@ -373,6 +373,7 @@ export class PollSocket extends SocketShape {
373
373
  _lost(reason, code = 1006) {
374
374
  if (this.readyState === CLOSED) return
375
375
  this.readyState = CLOSED
376
+ clearTimeout(this._closeTimer)
376
377
  if (this._sid) this._req('close', undefined, {}, 5000).catch(() => { })
377
378
  fire(this, 'close', { type: 'close', code, reason, wasClean: false, target: this })
378
379
  }
@@ -392,7 +393,7 @@ export class PollSocket extends SocketShape {
392
393
  // A proxy that cuts long requests shows up as a failure after a long wait: hold shorter.
393
394
  if (startedAt && now - startedAt > 5000 && this._hold > 4000) this._hold = Math.max(4000, Math.floor(this._hold / 2))
394
395
  await sleep(Math.min(2000, 250 * 2 ** Math.min(3, Math.floor((now - this._troubleSince) / 5000))))
395
- return this.readyState === OPEN
396
+ return this.readyState === OPEN || this.readyState === CLOSING
396
397
  }
397
398
  _ok() { this._troubleSince = 0 }
398
399
  async _recvLoop() {
@@ -429,17 +430,18 @@ export class PollSocket extends SocketShape {
429
430
  if (!this._flushQueued) { this._flushQueued = true; setImmediate(() => { this._flushQueued = false; this._flush() }) }
430
431
  }
431
432
  async _flush() {
432
- if (this._sending || !this._outbox.length || this.readyState !== OPEN) return
433
+ const live = () => this.readyState === OPEN || this.readyState === CLOSING
434
+ if (this._sending || !this._outbox.length || !live()) { this._closeIfDrained(); return }
433
435
  this._sending = true
434
436
  try {
435
- while (this._outbox.length && this.readyState === OPEN) {
437
+ while (this._outbox.length && live()) {
436
438
  let n = 0, bytes = 0
437
439
  while (n < this._outbox.length && n < 512 && (n === 0 || bytes + this._outbox[n].length <= 4 * 1048576)) { bytes += this._outbox[n].length; n++ }
438
440
  const batch = this._outbox.slice(0, n)
439
441
  const first = this._outSeq
440
442
  const body = packFrames(batch)
441
443
  for (;;) {
442
- if (this.readyState !== OPEN) return
444
+ if (!live()) return
443
445
  const started = Date.now()
444
446
  let r
445
447
  try { r = await this._req('send', body, { 'content-type': 'application/octet-stream', 'x-hive-seq': String(first) }, 60000) }
@@ -455,17 +457,54 @@ export class PollSocket extends SocketShape {
455
457
  this._outbox.splice(0, n)
456
458
  this._outSeq = first + n
457
459
  }
458
- } finally { this._sending = false }
460
+ } finally { this._sending = false; this._closeIfDrained() }
459
461
  }
462
+ // CLOSE DELIVERS WHAT WAS SENT FIRST, as a WebSocket's does (0.14.3 P0-1). A WebSocket hands its
463
+ // bytes to the OS on send(), so ws.close() still delivers them; this socket keeps them in _outbox
464
+ // until the relay accepts them, and close() used to drop that array: an edit made just before the
465
+ // agent left never reached the room while hive_edit had already said it was syncing (8-9 Nov mock
466
+ // run, both Antigravity VM agents). Now close() keeps sending for up to CLOSE_DRAIN_MS, then ends
467
+ // the session. The pending request keeps the process alive meanwhile, so an exit waits for it.
460
468
  close(code = 1000, reason = '') {
461
469
  if (this.readyState === CLOSING || this.readyState === CLOSED) return
470
+ if (this.readyState === OPEN && (this._outbox.length || this._sending)) {
471
+ this.readyState = CLOSING
472
+ this._closeWith = { code, reason }
473
+ this._closeTimer = setTimeout(() => this._endClose(false), PollSocket.CLOSE_DRAIN_MS)
474
+ this._flush()
475
+ return
476
+ }
477
+ this._closeWith = { code, reason }
478
+ this._endClose(true)
479
+ }
480
+ _closeIfDrained() {
481
+ if (this.readyState === CLOSING && !this._sending && !this._outbox.length) this._endClose(true)
482
+ }
483
+ _endClose(drained) {
484
+ if (this.readyState === CLOSED) return
485
+ clearTimeout(this._closeTimer)
462
486
  const had = !!this._sid
487
+ const { code, reason } = this._closeWith || { code: 1000, reason: '' }
463
488
  this.readyState = CLOSED
464
489
  if (had) this._req('close', undefined, {}, 5000).catch(() => { })
465
- setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: true, target: this }))
490
+ setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: drained, target: this }))
491
+ }
492
+ /** Resolves once nothing is waiting to reach the relay (or the socket is gone): the drain, observable. */
493
+ drained(timeoutMs = PollSocket.CLOSE_DRAIN_MS) {
494
+ const end = Date.now() + timeoutMs
495
+ return new Promise((resolve) => {
496
+ const tick = () => {
497
+ if (this.readyState === CLOSED || (!this._outbox.length && !this._sending)) return resolve(!this._outbox.length)
498
+ if (Date.now() >= end) return resolve(false)
499
+ setTimeout(tick, 25)
500
+ }
501
+ tick()
502
+ })
466
503
  }
467
- terminate() { this.close(1006, 'terminated') }
504
+ // terminate is for a dead link (the liveness check): nothing can be delivered on it, so no drain.
505
+ terminate() { this._closeWith = { code: 1006, reason: 'terminated' }; this._endClose(false) }
468
506
  }
507
+ PollSocket.CLOSE_DRAIN_MS = 10000
469
508
 
470
509
  /**
471
510
  * The socket every provider gets: a WebSocket, or long-poll where WebSockets are refused (above).
package/icr.js CHANGED
@@ -802,12 +802,17 @@ export function structuralMerge(base, a, b, opts = {}) {
802
802
  // ASYNC/AWAIT SYNCHRONIZATION (intent):
803
803
  // When a declaration was synchronous in base and became ASYNC in the merged result,
804
804
  // upgrade call sites inside async callers with `await` so the contract holds.
805
+ // ONLY a function that EXISTED in the base as sync (0.14.3 P0-5). A function new in this merge was
806
+ // written async by its author, together with its callers: nothing was "made async" under anyone.
807
+ // Counting it rewrote authored lines (`return f()` -> `return await f()`, credited to the author,
808
+ // silently), and two peers making the same insert at once stacked in the shared text: the room's
809
+ // own server.js read `return await await await admitMux(req, q)` (9 Nov).
805
810
  const asyncUpgrades = []
806
811
  if (lang.isAsyncDecl && lang.upgradeCallSitesToAwait) {
807
812
  for (const name of mergedNames) {
808
- const wasAsyncInBase = baseNames.has(name) ? lang.isAsyncDecl(base, name) : false
813
+ const wasSyncInBase = baseNames.has(name) && !lang.isAsyncDecl(base, name)
809
814
  const isAsyncInMerged = lang.isAsyncDecl(merged, name)
810
- if (!wasAsyncInBase && isAsyncInMerged) {
815
+ if (wasSyncInBase && isAsyncInMerged) {
811
816
  const upgraded = lang.upgradeCallSitesToAwait(merged, name)
812
817
  if (upgraded !== merged && lang.parses(upgraded)) {
813
818
  merged = upgraded
package/lang-js.js CHANGED
@@ -230,45 +230,63 @@ function isAsyncDecl(src, name) {
230
230
 
231
231
  // Upgrade free call sites of `fnName` to `await fnName(...)` if they are inside an async parent
232
232
  // and not already awaited.
233
+ //
234
+ // Left alone (0.14.3 P0-5): a call that is already awaited (`await f()`, `await (f())`); a call that
235
+ // IS the value an async function returns (`return f()`, `async () => f()`) -- OUTSIDE a try, where
236
+ // returning the promise resolves to the same value. Inside a try (its block or its catch) `return f()`
237
+ // is NOT the same once f is async: a rejection escapes the catch and a finally runs before f settles,
238
+ // so there it is upgraded, which is what the feature is for (Claude-cloud's review). A call that is an
239
+ // argument of an awaited call (`await g(f())`) is NOT awaited by that await and is upgraded too.
240
+ // Each position is inserted at most once.
233
241
  function upgradeCallSitesToAwait(src, fnName) {
234
242
  try {
235
243
  const ast = parse(src)
236
- const spots = []
244
+ const spots = new Set()
237
245
 
238
- function walkAST(node, parent, isAsyncScope = false) {
246
+ // awaited: this node IS the operand of an await (through parentheses or an optional chain only).
247
+ // inTry: this node sits in a try block or catch clause of the current function.
248
+ function walkAST(node, parent, isAsyncScope = false, awaited = false, inTry = false) {
239
249
  if (!node || typeof node.type !== 'string') return
240
250
 
241
251
  let childAsyncScope = isAsyncScope
252
+ let childInTry = inTry
242
253
  if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
243
254
  childAsyncScope = !!node.async
255
+ childInTry = false // a nested function has its own try scopes
244
256
  }
257
+ const passAwait = node.type === 'AwaitExpression' ? true
258
+ : (node.type === 'ParenthesizedExpression' || node.type === 'ChainExpression') ? awaited : false
245
259
 
246
260
  if (node.type === 'CallExpression') {
247
261
  let isTarget = false
248
262
  if (node.callee.type === 'Identifier' && node.callee.name === fnName) isTarget = true
249
263
  if (isTarget && childAsyncScope) {
250
- const alreadyAwaited = parent && parent.type === 'AwaitExpression'
251
- if (!alreadyAwaited) {
252
- spots.push(node.start)
253
- }
264
+ const alreadyAwaited = awaited || (parent && parent.type === 'AwaitExpression')
265
+ const returnedAsIs = !inTry && parent && (parent.type === 'ReturnStatement' ||
266
+ (parent.type === 'ArrowFunctionExpression' && parent.body === node))
267
+ if (!alreadyAwaited && !returnedAsIs) spots.add(node.start)
254
268
  }
255
269
  }
256
270
 
257
271
  for (const k of Object.keys(node)) {
258
272
  if (k === 'type' || k === 'start' || k === 'end' || k === 'loc' || k === 'range') continue
259
273
  const v = node[k]
274
+ // A try's block and its catch clause are "in try"; its finally is not (a return there is final).
275
+ const kInTry = node.type === 'TryStatement' ? (k === 'block' || k === 'handler') || childInTry : childInTry
260
276
  if (Array.isArray(v)) {
261
- for (const c of v) walkAST(c, node, childAsyncScope)
277
+ for (const c of v) walkAST(c, node, childAsyncScope, passAwait, kInTry)
262
278
  } else if (v && typeof v.type === 'string') {
263
- walkAST(v, node, childAsyncScope)
279
+ walkAST(v, node, childAsyncScope, passAwait, kInTry)
264
280
  }
265
281
  }
266
282
  }
267
283
 
268
284
  walkAST(ast, null, false)
269
- if (!spots.length) return src
285
+ if (!spots.size) return src
270
286
 
271
- spots.sort((a, b) => b - a)
287
+ const order = [...spots].sort((a, b) => b - a)
288
+ spots.clear()
289
+ for (const p of order) spots.add(p)
272
290
  let out = src
273
291
  for (const pos of spots) {
274
292
  out = out.slice(0, pos) + 'await ' + out.slice(pos)
@@ -279,6 +297,31 @@ function upgradeCallSitesToAwait(src, fnName) {
279
297
  }
280
298
  }
281
299
 
300
+ // STACKED AWAITS LEFT BY A CRDT UNION (0.14.3 P0-5-3). Two peers making the same sync -> async
301
+ // change at once each insert `await ` before the same call; the shared text keeps BOTH inserts, so
302
+ // the room reads `await await f()`. No merge can prevent that (each peer's merge is right on its own),
303
+ // so the engine removes it afterwards. Returns the [start, end) spans to delete: for every
304
+ // `await await x`, the OUTER `await` and the whitespace after it, and only when nothing but whitespace
305
+ // separates the two (so `await (await x)`, a comment, or a string is never touched). On the same
306
+ // converged text every peer computes the same spans, so their deletes are of the same characters.
307
+ function stackedAwaits(src) {
308
+ let ast
309
+ try { ast = parse(src) } catch { return [] }
310
+ const spans = []
311
+ const walk = (node) => {
312
+ if (!node || typeof node.type !== 'string') return
313
+ if (node.type === 'AwaitExpression' && node.argument && node.argument.type === 'AwaitExpression' &&
314
+ /^await\s+$/.test(src.slice(node.start, node.argument.start))) spans.push([node.start, node.argument.start])
315
+ for (const k of Object.keys(node)) {
316
+ if (k === 'type' || k === 'start' || k === 'end' || k === 'loc' || k === 'range') continue
317
+ const v = node[k]
318
+ if (Array.isArray(v)) { for (const c of v) walk(c) } else if (v && typeof v.type === 'string') walk(v)
319
+ }
320
+ }
321
+ walk(ast)
322
+ return spans.sort((a, b) => a[0] - b[0])
323
+ }
324
+
282
325
  // Find callers of `fnName` that are synchronous (cannot be auto-awaited without changing the caller's signature)
283
326
  function syncCallSitesOf(src, fnName) {
284
327
  const syncCallers = []
@@ -641,6 +684,7 @@ export const javascript = {
641
684
  declBody,
642
685
  isAsyncDecl,
643
686
  upgradeCallSitesToAwait,
687
+ stackedAwaits, // spans of `await await` a CRDT union left (P0-5-3)
644
688
  syncCallSitesOf,
645
689
  renameRefs, // rewrites every matching identifier (kept for reference)
646
690
  renameFreeRefs, // scope-aware — only rewrites references that resolve to the binding
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,7 +36,11 @@
36
36
  "hive-version.js",
37
37
  "hive-pr-staleness.js",
38
38
  "hive-pr.js",
39
- "hive-branch.js", "hive-land.js", "hive-repos.js", "hive-room-mode.js", "hive-room-watcher.js",
39
+ "hive-branch.js",
40
+ "hive-land.js",
41
+ "hive-repos.js",
42
+ "hive-room-mode.js",
43
+ "hive-room-watcher.js",
40
44
  "hive-mailbox.js",
41
45
  "hive-obligations.js",
42
46
  "hive-receipts.js",
@@ -188,5 +192,9 @@
188
192
  "doc": "docs",
189
193
  "test": "tests"
190
194
  },
191
- "author": "Ganesh Shivlal Kute <support@getgrm.tech>"
195
+ "author": "Ganesh Shivlal Kute <support@getgrm.tech>",
196
+ "repository": {
197
+ "type": "git",
198
+ "url": "git+https://github.com/Ganesh-Kute/HiveCode.git"
199
+ }
192
200
  }