Repository navigation
fix(runtimes): en Windows los CLI instalados por npm son shims .cmd, y spawn no los encuentra - #45
Open
sietemil400 wants to merge 899 commits into
Open
sietemil400 wants to merge 899 commits into
sietemil400 wants to merge 899 commits into
Conversation
…n turns
A group cascade was the one turn shape nobody could interrupt. It never
registered in active-turns, so `POST /turns/abort` had nothing to find — and it
is the shape most worth stopping, since a single owner line fans out into up to
MAX_TURNS_PER_MESSAGE full tool loops.
Two halves:
**Addressing it.** The abort route grew a third target form, `{ channel:
"group", thread_id }`. That is the general one: a channel holding many threads
has to say which, and a project runs any number of rooms at once, so `channel`
alone would stop whichever the map happened to hold. The cascade now registers
under that key.
**Stopping it.** The signal reaches the speaker mid-run AND the resolver checks
it between speakers, so an interrupted room does not start the next agent's
turn — without that, "stop" ended one reply of ten. Whatever the stopped speaker
streamed is real work the owner watched happen, so it is written to the
transcript with the tool rows that really ran, exactly as the 1:1 chat does.
`wasAborted` moved to core/agent/abort.js: both sides need it and core cannot
import from host (rule 8), so the alternative was a second copy.
**Budget.** A group speaker gets GROUP_TOOL_ITERS (50), not the web ceiling it
was inheriting by accident from `channel: WEB` — that string picks the channel
PROMPT. Same failure as the judge rounds and the same answer: the budget belongs
to the owner's turn, and the runs it fans out into have to fit inside it. The
invariant that keeps it honest is asserted in a test:
GROUP_TOOL_ITERS * MAX_TURNS_PER_MESSAGE <= WEB_TOOL_ITERS
active-turns.js lands whole here: `threadTurnKey` shares a hunk with the live
turn timeline, whose consumers arrive with the web panel change that needs them.
Which test would have caught it: tests/group-turn-control.test.js — aborting a
room stops the cascade and keeps the partial reply.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ling The tool budget is keyed on the SURFACE, so every place that decides a channel string decides a budget — and each of those was a way to claim the web chat's run-to-completion ceiling without asking for it. Three are now pinned in one file, next to the rule they protect: - `/agents/:slug/chat/stream` defaulted to `web` while its `/exec` and `/chat` siblings defaulted to `api`, so a client that simply omitted `channel` got the uncapped budget. Nothing shipped hit it — the panel is the route's only caller and always sends "web" — which is why it was worth closing: a default like that only springs on the NEXT client. - `buildVoiceChannelContext` passed an unrecognised channel through verbatim (fixed in 607a829), so `POST /voice/turn { channel: "web" }` would have run a SPOKEN turn uncapped. - a group speaker's ceiling times the cascade limit must not exceed one 1:1 web message: `GROUP_TOOL_ITERS * MAX_TURNS_PER_MESSAGE <= WEB_TOOL_ITERS` (de19dd1). Raise either factor and this test says so. Also relaxes `WEB_TOOL_ITERS > TELEGRAM_TOOL_ITERS` to `>=`: both are the same runaway backstop since 3dc7f2c, and the assertion was about web never being the tighter of the two. The two exec routes now share one harness, because the point of the pair is that two routes on one agent must resolve a channel to the same ceiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… nothing A turn belongs to the daemon; a chat pane does not. Panes mount and unmount as navigation moves between the Inbox, a project and the phone shell, so a turn started in one place became invisible the moment you left it — no way to tell a working agent from an idle one, and a finished answer went unnoticed. **Live activity.** active-turns keeps a compact, render-ready timeline of the visible work — text segments plus tool starts and results — instead of only the accumulated text. Text alone loses the actual tools and makes a still-working turn look idle. A refresh, or a second tab, now shows the same tools the original pane was watching. The list surfaces (Inbox, Chats) read one daemon-owned status through a single device-wide registry, so every rail shows the same thing and a finished answer stays marked unread after its pane is gone. Deliberately a small transport shape: never hidden reasoning, never the abort hook. **Never an empty bubble.** The shared floor from bbdbfe7 reaches the web super-agent routes here, blocking and streamed. Without it `result.text` went out verbatim, so a dud turn rendered as an empty bubble AND was written to the thread as an empty assistant row — which is what the NEXT turn reads back as the answer it gave. A real reply passes through untouched and costs nothing. api/super-agent.js carries both changes because they interleave in the same handlers. **Ledger order.** logWebTurn takes the event timeline, so prose that preceded a tool keeps its real position. Before, it existed only in the browser: a refresh lost it, and the final-only ledger made it look as though APX had erased part of the answer. Which test would have caught it: tests/web-empty-floor.test.js — every web route shape ends with something, including an interrupted turn that wrote nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to c80f916, which made `apx send <from> <super-agent> "…" --deliver` run the real orchestrator. Three places still told the old story: - the bundled apx skill said "the super-agent itself is not an a2a target — reach it with `apx exec`". That is now exactly backwards: `apx exec` is for an owner-authored turn, and using it from A2A impersonates one. - the same skill's `<agent>` argument now documents the configured name and the technical aliases, and which side `--project` follows. - the super-agent page gains the A2A section it had no reason to have before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn ends when the model stops calling tools — which is also what it does when
it merely ANNOUNCES its next step ("ahora genero el SRT") and writes no call.
Nothing is finished, nothing was asked, and the task sits there until a person
types "seguí". The super-agent has been continued automatically since b071e70;
every project agent was still waiting to be poked, so Magui stalled in the web
chat where Roby did not.
runAgentTurn (run-turn.js) — the shared engine behind the 1:1 chat routes and
the group cascade — now runs the same applyJudgeLoop on the same switch
(super_agent.judge.continue_unfinished, on by default) with the same exclusions
from continuableTurn.
EVERY CHANNEL a project agent answers on, not only the watched ones. The failure
is the model's, not the surface's, and the two exclusions already spend the call
where it pays: a turn that ran no tools is chat, and a turn that closed by
ASKING is waiting rather than unfinished. Those cut differently per surface for
the right reason — a bounded channel reserves its last iteration for the
wrap-up, so a turn that used its whole budget ends by asking and is excluded,
leaving only the turn that quit early; the run-to-completion surfaces have no
wrap-up, so the judge is the only thing that notices, and there a human is
watching. Cost is one model call per JUDGED turn, and after the exclusions the
judged population is "ran real tools, then went quiet without asking anything".
Two smaller things this needed:
- runAgentTurn returns `endedAwaitingUser` (and `judge`). Without it the wrap-up
exclusion would have read undefined and silently failed open — the exact
2026-08-30 triple-recap regression continuableTurn's header documents.
- judgeCompletion takes `fallbackModel`, the model that ran the turn. A project
agent can be the only agent an install has; resolving to "" when neither
judge.model nor super_agent.model is set would have shipped the feature on by
default and doing nothing. Both explicit knobs still win.
Wired to 3dc7f2c's shared-budget contract from the start: applyJudgeLoop gets
the turn's total via maxIters, and each round runs on the remainder.
Which test would have caught it: tests/judge-loop.test.js, "runAgentTurn: a
project agent that stopped mid-task gets continued" — no judge trail before the
fix. "runAgentTurn: a project agent that wrapped up asking the user is not
judged" also fails before it (endedAwaitingUser was not returned at all). Both
verified red against the previous run-turn.js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…answers by voice Five pieces of one feature: the phone notices it is in a car, keeps the daemon posted on where that car is, the daemon reminds the owner once when they pass something they still have to buy, the reminder is answerable without looking at the screen, and every automatic Telegram reply is spoken while the trip lasts. Android Auto as a second trip source. The existing notification listener now also recognises the projection app's own session notification, so a trip opens the moment the phone plugs into a head unit — route or no route. Setup invitations and the developer head-unit-server notice are not sessions. Both sources are tracked independently: a Maps route that ends with the car still connected keeps the same trip and the same trip_id. Live position. A foreground service typed `location` posts one point plus accuracy to the new POST /api/mobility/positions every 30 s / 150 m, started and stopped by the trip. It is what buys while-in-use location off screen WITHOUT asking for ACCESS_BACKGROUND_LOCATION; the ongoing notification is the permission model. No route geometry, no history. Proximity reminders. Each position is matched against pending tasks that name a physical errand; a matching place within 2 km earns exactly one card, ever, per errand per trip — persisted before the send, so a failed Telegram call does not re-announce it two seconds later. The card carries Navigate and Add stop (Maps deep links) plus I'll go / Not today; "I'll go" is a promise, and when the trip ends APX asks whether the errand actually happened, closing the task only on Done. Spoken replies. While a trip is active a Telegram answer goes out twice — the voice note, then the same words as a flagged transcript carrying the keyboard. Length comes from running the TURN in voice mode (modes/voice.md + modes/mobility.md), not from truncating afterwards. TTS or ffmpeg failing costs the audio, never the message. Two bugs the real device found, both with regression tests: - The session notification on a Galaxy A55 carries FLAG_FOREGROUND_SERVICE and no FLAG_ONGOING_EVENT. Gating on the ongoing flag alone detected nothing at all — the one signal the feature exists for. (AndroidAutoDetectorTest.recognisesWhatARealHeadUnitSessionActuallyPosts) - Keying the one-shot on (trip, task, PLACE) turned two errands into fifteen alerts and eight Telegram messages in ninety seconds on a real drive. The key is (trip, errand); the nearest matching place is the answer. (tests/mobility-geofence.test.js "one errand is one reminder") tests/telegram-callbacks.test.js now sets its own APX_HOME — its new mobility cases were writing alert rows into the developer's live ~/.apx/mobility.json. Coverage floor raised to line 79 / function 73 in the same commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t a car The detector was written against a real projection session; the steps that produce one — Android Auto developer settings, the head unit server, the adb forward — were only in a session transcript. Includes the trap that cost a build: the head-unit-server notification is itself persistent and is not a car. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ack to Maps
The strip offered "Abrir Maps", which is the one place it never needed to go —
Maps is already open, it is what the trip is being read from. What the owner
could not see anywhere was what APX is actually watching for on this drive.
The whole strip now opens that list: every errand with a matching place on the
trip, nearest first, each row opening in Maps. It carries a live count
("8 cerca"), and a drawn car mark instead of the 🚗 emoji, whose shape and
colour were whatever font the OEM shipped. The list comes from a new read-only
GET /api/mobility/trip; asking never fires a reminder or spends a one-shot.
Three bugs found while testing this on a second phone (Honor 400, Magic OS),
each with a test:
- A daemon restart mid-drive silently ended proximity evaluation for the rest
of the trip. The in-memory trip map is empty after a restart, so every
position came back "trip-ended" while the phone kept uploading and the
persisted state kept saying the trip was on. The persisted context now
decides when the process has no opinion.
- The place cache only expired by distance, so an errand added from the phone
mid-drive was invisible until eight kilometres later. It now also expires
after ten minutes.
- A trip that outlived the app — reinstall, force stop, an OEM power manager
freeze — left the trip flag set with no GPS service running. The listener
resumes tracking on rebind, and MainActivity does it too: a foreground start
is the one that is always allowed, and Magic OS refuses it from the listener.
The list is capped at eight places; a city search matched thirty, which is a
directory, not something you read at a red light.
tests/mobility-api.test.js now sets its own APX_HOME — a test added here wrote
a fake trip over the live ~/.apx/mobility.json and stopped the running daemon
from evaluating a real drive (AGENTS.md rule 1).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t exposed Covers what the strip does now (opens the trip's errands, read-only endpoint, capped list) plus the guardrails the second phone forced out: resume tracking from both the listener and the foreground activity, survive a daemon restart, and expire the place cache on time as well as distance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s the model
A tag is a label the owner invents; a category is a closed set the SYSTEM acts
on. That difference is the whole feature: `trip` means "an errand, at a place",
and the daemon can route on it without asking a model to read the title and
guess.
That guess is what this replaces. A mobility reminder for a task with no
location has to geocode and search OpenStreetMap for somewhere that might
satisfy the errand. A `trip` task carrying its own coordinates needs none of
it — no geocoding, no place search, no LLM call. Same reminder, no network and
no tokens. `location.radius_m` lets one errand set its own idea of "near".
apx task add "Buy ibuprofen" --category trip --place "Pharmacy on Main" \
--at "-41.13,-71.31"
Tags are untouched; a task can carry both. The web list draws a mark for a
category that has one, and `general` draws nothing — an icon on every row
carries no information. Half a coordinate is never stored: everything
downstream would read it as an answer.
Also: emoji no longer reach anything that gets SPOKEN. Two failures, one cause
— a TTS model hums for seconds on a 📍, and on a car head unit the Assistant
reads the notification card aloud, so the pin came out as its Unicode name
mid-sentence. The written transcript keeps them; only the audio and the car
card are stripped. The card also carries the whole reminder now rather than a
100-character headline: a driver hearing half of it has to pick up the phone,
which is the one thing this feature exists to avoid.
And the trip announcement now also goes out from TripLocationService. On Magic
OS a background notification listener's network calls are dropped while its
foreground service talks fine, so the daemon was receiving positions for a trip
it had never been told had started — and answered every one of them
"trip-ended". State-only, so a repeat is a no-op.
Verified end to end on a second phone: on the road with no route at Pioneros
km 8, the alert fired on its own from the pinned task with zero fetches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wner's to give The phone was reporting APX as a heavy app while it was doing nothing, and the reason was not the trip: three things ran all day whether or not anything was happening. - The mascot overlay asked for a frame every 32 ms forever — a full software-rendered redraw thirty times a second, over whatever app was actually on screen, from the moment APX went to the background. It now animates only while it is dragged, hopping, or showing a message; at rest it holds its pose and wakes twice per blink cycle (~0.4 frames/s). - The embedded /mobile kept running behind the launcher: its socket, its timers, its animations. onPause()/pauseTimers() on the way out and onResume()/resumeTimers() on the way back. Native notifications are unaffected — they arrive over the foreground service, not the WebView. - The daemon socket redialled every 15 s indefinitely. Away from the LAN that is thousands of radio wakeups that cannot succeed. The ladder is now capped at 15 s during a trip and 5 min outside one, does not climb at all with no active network (registerDefaultNetworkCallback wakes it instead), and a trip opening reconnects immediately rather than waiting out an idle backoff. Trip time is still allowed to be expensive: that is when GPS runs and when a dropped socket has to come back in seconds. Also: the native menu now offers the battery-optimisation exemption, reporting the real state and opening Android's own dialog — without it the OS defers the start and end of a trip while the phone is in a pocket, and with it APX may run while idle, so the decision stays the owner's. And the trip banner queued one place-recheck timer per update instead of keeping a single pending one. tests/android-battery.test.js is what would have caught all four: it fails on the unconditional postInvalidateDelayed(32), on a WebView with no pauseTimers, and on a single reconnect ceiling. Verified with the repository-local wrapper on HEAD plus this change: ./gradlew testDebugUnitTest assembleDebug — 27 JUnit tests green, APK builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram jumped in Manu's face on the phone that already has Telegram
installed on it. The panel has answered that question per device since
lib/channels.ts, but the APK never came through that code: MascotOverlayService
holds its own socket so it can bubble while the WebView is shut, and a
foreground service cannot read the browser's localStorage. Worse, what reached
it was a list of bare strings — it could not have filtered if it wanted to.
So the daemon tags every bubble with the channel it is ABOUT, and the phone
filters on the tag:
- `notices: [{text, channel}]` rides the frame beside the flat `notifications`
an already-installed APK still reads. Five tags and the list is closed by
construction — telegram, group, a2a, routine, mobility (NOTICE_CHANNELS).
- A delivery is tagged by what it IS, not by the ledger channel it was filed
on: the errand notice rides Telegram and the routine one is filed on web, so
muting Telegram must not silence the car. That is the split the owner
actually wanted.
- One gate in the service, ahead of the bubble AND the sound AND the Android
Auto card. A muted channel is still there to read in /mobile: this decides
what interrupts, not what exists.
- An untagged line — an older daemon, the test action — always rings. News
lost in silence is the one failure the owner cannot see.
Two editors, one SharedPreferences store: the native menu (⋯ → "Avisarme de…",
checkboxes) and the same five chips inside Notificaciones in the panel, which
reach it over the APXAndroid bridge. Ticking one refreshes the other. Only
explicit answers are stored, so a default changed later still reaches a phone
that never had an opinion — and Telegram starts off, for the reason the web
already starts it off there.
Verified against the real toolchain rather than by reading the source: the APK
builds and its 29 unit tests pass (JDK 17 + the Homebrew SDK, both present on
this Mac despite an older note saying otherwise). That build caught a bug the
JVM tests structurally cannot see — Android ships its own org.json, which has
no keySet().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the device" The phone was one screen, so its route could be `/mobile` and mean the one thing. Wanting a second screen made that name wrong twice over: `/mobile/tasks` says the device twice and the subject once, and `/m/` was already occupied by the PANEL's modules. So both moved. /m/chat /m/tasks /m/commitments the phone, behind a bottom tab bar /inbox /code /desktop the panel's own modules, at top level `/mobile/*` still lands: it redirects. Not politeness — the Android shell hardcodes the string (MainActivity even whitelists paths by that prefix), printed QR codes carry it, and an installed PWA keeps whatever start_url it was installed with. `/m/inbox` and friends redirect too (a static segment outranks the `/m/*` splat in React Router's ranking), with query and hash intact, because `/code?pid=..&edit=..` is a live handoff from the Artifacts tab. The two new screens are CROSS-PROJECT and grouped by WHEN. The panel hangs tasks off a project because that is where they are stored, but nobody standing in a queue thinks "let me check repo 7" — they think "what do I owe today". So the buckets are Overdue / Today / Tomorrow / This week / Later / No date, compared by calendar day (a task due today at midnight is not overdue at 9am, and a red badge that is on every morning stops meaning anything), and the project drops to a caption. Tapping opens a sheet with the verbs, because the verbs are the whole reason to open one. The tab bar is hidden inside a chat: that screen ends in a composer, and a nav bar under it sits where send goes. Two different badges on that bar, on purpose: tasks show how many are open, promises show how many are LATE. An "open promises" count would sit there permanently; the red one is useful precisely because it should normally be absent. And the desktop inbox stops grouping by channel. The grouping was quietly the enemy of the sort — an inbox answers "what happened last", and eleven channel buckets meant the newest thing on screen depended on which bucket it fell into. Flat and by recency now, the same shape as /m/chat, with ChannelTag on every row: the channel says what a conversation IS without deciding where it sits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported the first evening the tab bar existed: completing a task on the phone showed "• Hecha" in the bottom-right corner — which is now exactly where the tabs are, and in a chat exactly where the send button is. The toast covered the control that raised it. The container was `fixed bottom-4 right-4` for every surface, which was right when the bottom-right corner was empty. Under `max-sm` it now goes to the top, clearing the status bar with `env(safe-area-inset-top)` and spanning the width instead of hugging a corner that is 320px wide on a 375px screen. The panel is untouched: at 1280×800 it still sits 16px off the bottom-right. The e2e check MEASURES it — the toast's bounding box against the tab bar's — rather than asserting the class string, because what matters is where it lands, not how it was spelled. It fails on the old CSS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tles it "Buy bread" is not a place. It is the corner bakery or either supermarket, and which one is right depends on where the car is. The old code answered that by re-searching on a timer — a place query every ten minutes to re-learn the same answer, and a task added at a red light still invisible for ten of them. The trip now keeps a plan: one entry per errand, every candidate that could satisfy it, and the one it currently points at. - Built when you get in the car, from the origin the phone reports, instead of lazily on the first sample taken 800 m down the road. - Re-ranking as the car moves is arithmetic on coordinates already held. - A search happens only when the car leaves the searched area by 8 km, or when the open-errand list itself changes — a local file read, checked every sample, so the red-light task is live on the next one. The timer is gone. - One candidate IS the decision, and so is a "voy": both lock the errand, and a locked errand is never re-searched or re-pointed. Once all are locked the plan is finished and the rest of the drive costs nothing. A normal drive now costs one place search; a drive of pinned tasks costs zero. The phone's list stops being strictly worse than the chat for the same information. It was setItems(), where a tap could only mean "open in Maps" — no way to say "voy", no way to add a stop without losing the route. It is now one card per errand with the same four chips the Telegram card offers, and POST /mobility/errands/answer records a chip pressed there exactly as the Telegram one does: same alert, same one-shot, same follow-up after the trip. A card that still has several possible shops says so, rather than presenting the nearest as a decision APX has made. Also: "en el súper" and bread reach the need vocabulary at all (both matched nothing, article-anchored so "súper importante" still does not), and the transcript under a voice note is a quiet [Transcripción] line instead of a 📝 banner — the audio is the message. Verified on the road: silent with La Anónima 2.5 km off, one card at 1.3 km, plan prepared before the first GPS sample, and no follow-up left hanging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…app down Reproduced on a real drive, not in a test: Maps starts navigating while APX is not on screen, the notification listener starts the tracking service from the background, and startForeground(type=location) throws SecurityException — "the app must be in the eligible state". The app died at the exact moment the owner needed it, and Android showed "APX continúa fallando". ACCESS_FINE_LOCATION was granted the whole time. It is a while-in-use permission, so holding it is not sufficient; the app also has to be in an eligible state, and a background notification listener is precisely not. The permission check that would have caught the other case ran AFTER the call that throws, so it never got the chance. - canTrack() now gates startForeground instead of following it. - startForegroundCompat() returns false instead of throwing. Losing the GPS stream costs this leg's proximity reminders; a crash costs everything. - The trip is announced to the daemon either way — a trip we cannot follow is still a trip, and the daemon already has an origin. - Recovery was already there and now actually runs: opening APX makes the app eligible and MainActivity retries the start. "Remove battery restriction" makes the background start succeed on its own. Also paints the errand sheet's dialog frame in its own ground colour, so the title and button row stop reading as a different surface from the cards. Verified: same scenario, zero crashes, trip announced, tracking recovered on resume, and the sheet's Voy chip locks the errand from the phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Location being granted is not the whole story — a location-typed foreground service also needs the app to be in an eligible state, and the listener that detects a trip runs in the background. Says what the owner actually loses without the battery exemption (no position stream until APX is opened) rather than only that trip events arrive late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Locking an errand froze its `distance_m` along with its chosen place, so the
number the proximity check compares against the radius stopped moving the
moment the errand settled.
That is every errand that matters. A `trip` task carrying its own location
locks on sight ("only-candidate"/"pinned"), and trip-event.js warms the plan
from the ORIGIN before the driving starts — so the frozen number was the
distance from where the drive began. The driver pulled up outside the pharmacy
and evaluateMobilityPosition was still asking whether the pharmacy is within
2 km of their house.
The lock answers "which shop", which is settled. It does not answer "how far",
which is the whole basis of proximity and changes with every sample. So rank()
now re-measures a locked errand and keeps only its place.
Every existing test evaluated at a single position where the place was already
in range, so the frozen distance happened to be the right one. The regression
test drives: out of range first, then arriving.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
47d06d9 started sending `⚙️ <tool>` to the chat before every tool call. The channel prompt one directory over had been promising the opposite the whole time: "the user sees only your text segments — never your tool calls, args, or intermediate results." The code stopped honouring its own contract. Three costs, and the first is the one that matters. Telegram is a conversation, and "⚙️ run_shell" is untranslated machine vocabulary in a channel whose entire contract is natural prose. It also pushed one phone notification per tool step, so a ten-step turn buzzed a pocket ten times to say nothing. And each line was written to the ledger as an agent-voiced outbound message — the exact shape stores/messages.js filters out of the rolling history, because history in the agent's own voice is what teaches a weak model to WRITE tool names instead of calling tools. What the owner sees now is what the two-segment discipline always intended: the model's own opening line ("Reviso eso"), then quiet work, then the closing message carrying the result. The typing indicator covers the middle — the caller already holds it open on a 4s timer, and each tool start refreshes it. Nothing is lost from the audit trail: the `tool_result` branch still records every call with its args and result, for the web timeline and the other channels. Only the chat stops receiving them. A turn that dives straight into tools now stays silent until the model speaks, rather than having a line invented on its behalf. That is the older, better rule: never write in the agent's voice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MascotOverlayService is what holds the connection to the daemon and raises every bubble, sound and Android Auto card, and only MainActivity ever started it. So the phone went silent in two situations where nothing looks broken from the outside: after a reboot, and after APX updated itself — an install force-stops the package and the service does not return. Measured on both phones before this, not inferred: zero ServiceRecords right after `adb install -r`, the service back the instant MainActivity ran. "The notifications stopped working" and "I have not opened APX since Tuesday" were the same sentence. StartupReceiver listens for BOOT_COMPLETED and MY_PACKAGE_REPLACED and starts the service. Both are system broadcasts, so the receiver has to be exported — a private one is never handed them. Two things it deliberately does not do. It does not start on an unpaired phone: the service would stopSelf() the moment it looked, so a phone that never finished pairing keeps costing nothing. And it does not let Android's refusal escalate — a boot broadcast is exactly where a foreground service start may be denied, and SecurityException and ForegroundServiceStartNotAllowedException are both RuntimeException, caught here the same way TripLocationService catches them. Going quiet until APX is next opened is the behaviour we already had; taking the phone's boot down with it is not. Still to verify on a device: the phone came off USB mid-test, so the MY_PACKAGE_REPLACED path is unit-guarded but not yet watched working on hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red CI on every push since 2026-08-30, and the reason nobody could reproduce it is the bug: `grepFiles` tries ripgrep and falls back to a Node walk, so which code path runs depends on whether the machine has `rg`. A dev machine with `brew install ripgrep` never reaches the fallback. The CI runner never reaches anything else. The fallback began by calling readdirSync on the search path, which throws ENOTDIR when that path is a FILE. The catch swallowed it, the queue emptied, and the answer came back with zero matches — not an error, an empty result, to a question about a file sitting right there with the word in it. `grep(path: "work/marketing/magui/brain.md")` is how an agent reads one known file, so every install without ripgrep has been quietly told its own notes are empty. Same shape as the bug the failing test was written for: the call succeeds, so nothing says the agent was working from nothing. Two more, found while confirming the first against both backends: - the rg branch grouped hits on `last.file`, the RELATIVE label, comparing it to the absolute path — so it never matched, every hit opened a new group, and `files_with_matches` was a second copy of `total_matches`. Four hits in two files reported four files. - a file searched by name was labelled "", because path.relative() of a file against itself is the empty string. Both backends now agree on all three, which the new tests check by running every case twice: once as the machine is, once with PATH emptied so the rg branch throws and the fallback runs. Whichever machine executes them, the path that was broken is exercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# [1.84.0](agentprojectcontext/apx@v1.83.2...v1.84.0) (2026-09-02) ### Bug Fixes * **agent:** a project agent in the web chat stopped every 9 actions to ask ([9d8a22a](agentprojectcontext@9d8a22a)) * **agent:** the same "want me to keep going?" three times, and a web chat that never finished ([951fe95](agentprojectcontext@951fe95)) * **android:** a foreground service Android refuses must not take the app down ([5c4cf8b](agentprojectcontext@5c4cf8b)) * **android:** the notification socket comes back on its own ([10452f2](agentprojectcontext@10452f2)) * **mobility:** a settled errand is a fixed place, not a fixed distance ([123ad1e](agentprojectcontext@123ad1e)) * **routines:** an agent in one project reading another project's files ([98d0a77](agentprojectcontext@98d0a77)) * **routines:** the watch explaining twelve times a day why it stayed quiet ([80e3de2](agentprojectcontext@80e3de2)) * **runtimes:** a probe we killed is not evidence the binary is missing ([821ed36](agentprojectcontext@821ed36)) * **tools:** grep of a single file answered "not found" without ripgrep ([b404d88](agentprojectcontext@b404d88)) * **voice:** an unrecognised channel falls back to api, not to itself ([607a829](agentprojectcontext@607a829)) * **web:** a toast stops landing on the tab bar it is confirming ([0d070bc](agentprojectcontext@0d070bc)) * **web:** the tools switch forgot your choice in every new chat ([8a244b5](agentprojectcontext@8a244b5)) ### Features * **a2a:** addressing the super-agent reaches the real orchestrator ([c80f916](agentprojectcontext@c80f916)) * **agent:** one turn, one budget — judge rounds stop reissuing the ceiling ([a9c2fbd](agentprojectcontext@a9c2fbd)) * **agent:** the completion judge stops being a super-agent privilege ([a911dd2](agentprojectcontext@a911dd2)) * **agent:** the never-silent floor stops being Telegram's alone ([bbdbfe7](agentprojectcontext@bbdbfe7)) * **android:** the phone picks which channels may interrupt it ([e207e17](agentprojectcontext@e207e17)) * **group:** Stop reaches a room, and one owner line stops costing ten turns ([cc86dd1](agentprojectcontext@cc86dd1)) * **mobility:** an errand is a choice between shops, and the plan settles it ([6d255a2](agentprojectcontext@6d255a2)) * **mobility:** Android Auto opens a trip, GPS feeds it, and the car answers by voice ([51bb1a5](agentprojectcontext@51bb1a5)) * **mobility:** the trip banner shows the errands instead of a link back to Maps ([5a6f5ca](agentprojectcontext@5a6f5ca)) * **tasks:** a task can say what KIND it is and where — and that skips the model ([9de411c](agentprojectcontext@9de411c)) * **telegram:** every tool the turn runs shows up as an activity line ([47d06d9](agentprojectcontext@47d06d9)) * **web:** a chat row shows what its turn is doing, and never renders nothing ([66f28d3](agentprojectcontext@66f28d3)) * **web:** a stop button that stops, and a message that interrupts ([674693e](agentprojectcontext@674693e)) * **web:** the phone grows tasks and promises, and /m stops meaning "the device" ([43655fb](agentprojectcontext@43655fb)) ### Performance Improvements * **android:** idle costs nothing, and the battery exemption is the owner's to give ([d0947a8](agentprojectcontext@d0947a8)) ### Reverts * **telegram:** a tool name is not something a person says ([dae5d10](agentprojectcontext@dae5d10))
"Hoy no" answered a question nobody asked. The card asks about ONE shop — the branch you happen to be driving past — and the answer silenced the whole task for the rest of the trip, including at the other supermarket four blocks on. Declining a place and abandoning an errand are different things and had one button between them. So the second answer is now «Avisar en la siguiente», and it declines the PLACE: the errand stops counting as announced, so a different shop that satisfies it can still speak up later in the same drive, and the shop just waved past never comes up again (declinedMobilityPlaces). When there is no next shop it goes quiet for the rest of the trip — which is exactly what "hoy no" did, so nothing is lost by having only these two answers. Dropping every reminder for the day is a different question and keeps its own button on the trip card. This does NOT re-open the spam the (trip, task) one-shot exists to close — a drive through Bariloche once sent eight messages in ninety seconds off a per-place key. The default is unchanged, told once and done. Every reminder past the first costs a deliberate press, one at a time, and only ever names a shop the owner has not already declined. Three surfaces, one meaning: the Telegram chip, the phone's trip sheet, and POST /mobility/errands/answer all take "next". The old "skip" still works everywhere, because the cards carrying that button are sitting in a phone's chat history and a button that answers nothing is worse than an outdated one. Note: the two Java files are NOT covered by any gate — CI runs no gradle step and this machine has no JDK, so TripPlacesSheetTest was not executed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked something at the wheel, the owner got 1:13 of audio that dictated a whole
A2A requirement out loud and ended on a colon. The prompt already said "one or
two short sentences" and the model already knew it — but the answer genuinely
contained a prompt to paste, and the model had exactly two options: read it out,
or drop it. It read it out. A rule with no third option is a wish.
So the third option now exists, in the format the reply already has. Every
driving reply goes out twice, as audio and as text, and until now those were the
same words. They no longer have to be: the spoken half is the FIRST PARAGRAPH,
the written half is everything. mobility.md tells the model exactly that —
put anything unspeakable below a blank line, and say in one clause where it
went ("te dejo el prompt en el chat web"), which is literally true because the
written half lands in this chat and in the web panel.
This is not shortening the answer after the fact. That only truncates
mid-sentence, which is why voice-note.js refused to do it and says so. Cutting
at a break the model was instructed to draw is a different thing: a reply with
no break is spoken whole, and one short paragraph — the common case — is
untouched.
A reply that is nothing but a code block has no sayable half at all. That comes
back "nothing-sayable" and the caller sends it as plain text, rather than
voicing punctuation at someone doing 60.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# [1.85.0](agentprojectcontext/apx@v1.84.0...v1.85.0) (2026-09-02) ### Bug Fixes * **mobility:** a voice note that reads out a prompt is not a voice note ([6306854](agentprojectcontext@6306854)) ### Features * **mobility:** waving past one shop is not dropping the errand ([fdffa9f](agentprojectcontext@fdffa9f))
The receiver landed in 10452f2 with its on-device behaviour unverified — the phone came off USB mid-test. It is verified now, and the answer is not uniform, which is the part worth writing down. On the Samsung A55 both halves work: the service returns after an `adb install` with nothing opened, and after a real reboot with zero ActivityRecords, so MainActivity provably never ran. On the Honor 400 the update half does not fire at all — broadcast confirmed dispatched, app not in stopped state, service never started. MagicOS refuses the background start and Honor encrypts app logcat, so the refusal our catch logs cannot be read on that device. Also the three ways a reboot test lies, all three of which produced a false pass here before one run was clean: `adb reboot` returning before the phone goes down, Samsung restoring the last task on unlock and starting the service itself, and reading "the service is running" without checking who started it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… says "working" Measured, not guessed: yesterday's Telegram ledger has 29 finished turns and 9 model-written intros. One turn in three said anything before going quiet to work. The ⚙️ activity lines hid it — every turn looked alive whether or not the model had spoken — and removing them (dae5d10) made the silence visible: ask something, get nothing until the answer lands. The discipline prompt described the intro as one of two segments. Described, not required, and a fast model reads a description as optional. So it now says the thing plainly — write it before the first tool call, every time — and, more usefully, says WHY, because that is what a model can reason from: the user is never shown tool calls, so a turn that opens straight into one is indistinguishable from being ignored for as long as the work takes. The channel prompt adds the Telegram-specific half: there, the first line is the only sign of life there is. The exemption stays exactly one case, and is now stated as the only one: a turn that calls nothing answers in one segment. Also fixes the coherence test this broke. It asserted `desktopVoice.length > cli.length` to check that "a text channel never carries the voice block" — two different channels with different blocks, where which is longer says nothing about what either contains. It only ever passed because two-segment.md happened to be shorter than single-segment plus voice.md, so a text prompt growing for unrelated reasons failed it. It now looks for the block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## [1.85.1](agentprojectcontext/apx@v1.85.0...v1.85.1) (2026-09-02) ### Bug Fixes * **agent:** the intro before a tool is not optional, and nothing else says "working" ([5ff30a4](agentprojectcontext@5ff30a4))
…-liner Two things that cost time today and were not written down anywhere. The build recipe was `./gradlew testDebugUnitTest assembleDebug`, which does not work on this machine and does not say why: it dies with "Unable to locate a Java Runtime" (Homebrew's JDK is not linked as the system `java`), and once that is fixed, with "SDK location not found" — local.properties is gitignored and ANDROID_HOME is unset. Worse, the SDK that turns up first when you go looking, under proyectos_varios/android-lab, cannot compile: platform-tools and system images, no `platforms/`, no `build-tools/`. The one that works is Homebrew's commandlinetools. Both exports are now in the block. That gap is why two Java files shipped unverified this morning. With the exports, the suite runs: 34 tests, 0 failures, TripPlacesSheetTest included. And the Honor finding gets the follow-up it was missing. Re-measured on MagicOS 10.0.0.207 / Android 16 — zero ServiceRecords a full minute after `adb install -r`, so the update half is not a slow broadcast, it simply does not fire. The recovery is one adb line, and it works with the screen off and the phone locked, so "open APX once" is something the machine can do rather than a chore for the owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes, one problem: the task list was built as an input queue for
agents, so its owner could not use it as a to-do list.
1. `description` next to `body`. `body` is the prompt an agent receives —
the panel literally labelled it "Prompt" and hinted "what the agent
receives when the task runs". That was the only long-text field, so
every task dictated to the super-agent came back written for a model.
`description` is what the OWNER has to do, in their words, and it is
what the panel, the phone and `apx task show` now lead with. The agent
prompt renders only when it exists — an empty "Prompt" box on top of a
grocery errand is an invitation to fill it.
Event-sourced, so old logs need no migration: a `create` line written
before the field simply projects `description: null` (covered by a test).
Reaches every surface: store, POST /projects/:pid/tasks, `apx task
add|patch --description`, the `create_task` tool (whose schema now says
which field is for whom) and the apx-task skill.
2. Ticking one off is ONE CLICK. It used to be three plus a modal — open
the "⋯", pick "mark done", confirm — on the single most frequent verb
in the screen. The status square IS the checkbox now, in the panel and
on the phone, and undo rides the toast (`ToastAction`, additive).
`rules/web-ui.md` asks for a dialog before an EXECUTION or a DESTRUCTIVE
change; finishing a task is neither, and the old dialog already said
`destructive={false}`. Dropping still confirms.
Verified against the live daemon: description/prompt split renders, tick
completes and the tick on a closed task reopens it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…versión
La regla 18 quedó escrita pero sin nada que la sostenga, y es justo el tipo de
regla donde eso no alcanza: el error no se ve. Un tipo mal escrito deja el
código en main con todos los gates en verde y la versión nunca sale. No hay
error, no hay log, no hay nadie mirando.
El hook chequea LA FORMA y nada más: que el tipo sea una palabra que la
herramienta de release conozca. Lee el vocabulario de .releaserc.json en vez de
repetirlo, así el gate no puede desfasarse de la config que está haciendo
cumplir. Va en node y no en bash como pre-push por eso: parsear JSON en shell
portable pide jq, que no está garantizado.
Lo que NO hace, y no puede: decir si el tipo es el CORRECTO. Un fix disfrazado
de `chore` está perfectamente bien formado, no publica nada, y es exactamente
lo que ningún gate ve. Esa mitad la sigue cargando rules/releasing.md y un
lector.
Medido antes de escribirlo, no supuesto:
- 300 mensajes reales de la historia contra el hook: 0 rechazados, los 13
merges incluidos. Un gate que rechaza los commits que ya están en el repo es
un gate que enseña a editar el check.
- Sin límite de largo, a propósito: el subject más largo de los últimos 300
tiene 117 caracteres. La regla de 100 que trae todo commitlint por defecto
habría rechazado historia real.
- Acepta `Merge`, `Revert "…"`, `fixup!`, `squash!`, el `!` de breaking y el
`chore(release): … [skip ci]` que escribe semantic-release.
El test no es vacuo — lo verifiqué rompiendo el hook de tres formas:
sin bit de ejecución → 1 test falla
aceptando todo → 2 tests fallan
lista de tipos hardcodeada → 2 tests fallan
La primera importa más de lo que parece: git saltea un hook sin permiso de
ejecución en silencio absoluto, y nada más lo agarraría.
Corregido en el mismo commit todo lo que este gate vuelve mentira, que eran
cinco lugares — dos de ellos decían literalmente que los commits no están
gateados (enforcement.md y human-model.md), más la fila que en el commit
anterior había puesto en la tabla de SÓLO CONVENCIÓN y que ahora se parte en
dos: la forma es mecánica, el juicio no.
Verificado: test:ci en verde, 2814 tests, 0 fallos, 0 skipped, 0 todo,
cobertura 81.73/74.33/75.65 sobre pisos de 79/72/73. lint limpio.
…ontroles y Desktop sale del menú Tres cosas del rail izquierdo que pidió Manu. 1. EL HUECO. `useVisibleCount` mide la lista flexible y decide cuántos avatares entran, pero salía con `if (!el || !enabled) return` ANTES de crear su ResizeObserver — así que un rail que arrancaba colapsado nunca tenía uno. Al expandirlo el efecto volvía a correr, pero `useLayoutEffect` corre con el DOM actualizado y los altos flex todavía sin resolver, así que esa única medición leía los dos slots del rail colapsado y dejaba el contador en 0. Nada lo recalculaba nunca más. Medido en el panel, 1280x1050 con 13 proyectos: la lista tenía lugar para 7 slots y renderizaba 2 —el "+13" y el botón de agregar— con 427px de rail vacío abajo. "Podrías hacer que el menú llegue hasta config así entran más": ya llegaba hasta Config (552 contra 564), lo que no hacía era usarlo. Después del arreglo: 7 renderizados, "+8", 42px sobrantes (el resto del floor). Ahora el observer se crea siempre, y se mide tres veces: sincrónicamente, otra vez en el frame siguiente (cuando los altos flex ya están resueltos) y en cada resize. 2. EL +N. Tenía `bg-muted/40`, un tono más tenue que los tiles de Config y Docs justo abajo, así que en oscuro leía como un tercer color en un rail que sólo tiene dos clases de tile: proyecto (con color, hasheado del nombre) y control (gris). Ahora usa exactamente el gris de los controles — verificado en vivo, los tres computan el mismo color. 3. DESKTOP. Fuera del rail. Es la ventana flotante de voz, se abre desde `apx desktop` y desde la bandeja, y el rail es el espacio vertical más escaso del panel. Se oculta el tile: la pantalla y la ruta /desktop quedan intactas, y el e2e ahora verifica justo eso — que no hay tile y que la pantalla sigue andando. Verificado en dev y en prod: sin tile de Desktop, los tres grises idénticos, y el rail lleno al cargar. El re-measure ante un resize de ventana NO lo pude ejercitar acá: el panel del browser corre oculto y sin frames no se entregan ni requestAnimationFrame ni ResizeObserver (un observer recién creado no dispara ni su notificación inicial). El camino de carga, que es el que se veía roto, sí quedó verificado punta a punta.
Chiquita y gris, debajo del logo. El panel ya mostraba la versión, pero sólo en la pantalla de APX Admin — que no es donde estás cuando te preguntás si lo que tenés adelante ya tiene tu último cambio. Sale del DAEMON, por la misma key de SWR que usa todo el que pregunta (una sola consulta para el panel, no una por consumidor). No de package.json horneado en el bundle: el bundle puede estar viejo y el daemon no, y después de un release que esos dos no coincidan es exactamente lo que querés poder ver. Va DENTRO del botón del logo, no al lado: el rail acomoda sus hijos con gap-3, así que un hermano abriría un hueco del alto de un tile para nueve píxeles de texto. Y no se dibuja nada hasta que la versión llega — un placeholder que se convierte en número mueve el logo en cada carga. Verificado en el panel: "v1.108.1", 9px, gris muted, 41px de ancho en un rail de 80.
# [1.110.0](agentprojectcontext/apx@v1.109.0...v1.110.0) (2026-09-14) ### Bug Fixes * **e2e:** el control de autonomía tiene cuatro modos, y el test seguía contando tres ([8a1760d](agentprojectcontext@8a1760d)) * **engines:** una respuesta cortada por el tope de salida se nota en ollama y anthropic, no sólo en los demás ([3ea56a4](agentprojectcontext@3ea56a4)) * **inbox:** el deep link a la conversación de un agente abre esa conversación, no la más reciente ([7461472](agentprojectcontext@7461472)) * **projects:** una carpeta que se movió se dice, y se vuelve a enganchar sin perder el id ([b796f5f](agentprojectcontext@b796f5f)) * **web:** el rail usa el alto que reserva, el +N es del gris de los controles y Desktop sale del menú ([d46bfe3](agentprojectcontext@d46bfe3)) ### Documentation * **rules:** el mensaje de commit decide si una versión sale, y eso no estaba escrito en ningún lado ([7c537a4](agentprojectcontext@7c537a4)) ### Features * **chat:** la cola se puede ordenar, y cualquiera de los mensajes puede salir ahora ([2538b3a](agentprojectcontext@2538b3a)) * **web:** el rail dice qué versión de APX estás mirando, abajo del logo ([7384624](agentprojectcontext@7384624)) * **web:** la carpeta de un proyecto se edita desde Config, y la que no está se marca con un ! ([c956129](agentprojectcontext@c956129)) ### BREAKING CHANGES * **rules:** 0 en 400 commits 31 de los últimos 39 subjects en es-AR, tipo y scope siempre en inglés Así que el doc describe la práctica real, no una que me gustaría. La trampa que nombra es la mitad de abajo de la tabla: nadie etiqueta mal un feature, lo que pasa es que un fix llega como `chore`, `refactor` o `test` porque eso es lo que parecía el DIFF. El criterio queda escrito como la pregunta que hay que hacerse: qué notaría un usuario, no qué tocó el parche. Cableado donde el repo lo pide: regla 18 en el hub, fila en las dos tablas de deep dives, índice de rules/README.md, y "17 reglas" → 18 en enforcement.md, más su fila en la tabla de SÓLO CONVENCIÓN — porque hoy no hay ningún gate que lo verifique y decirlo es la regla de esa tabla. Dos referencias que escribí y salieron falsas, corregidas antes de commitear: apuntaba a .githooks/commit-msg (no existe) y a human-model.md para la demora de propagación de npm (no la menciona). Verificado: docs-links y agents-md-contract 11/11 — y el primero me agarró los links a releasing.md mientras el archivo todavía no estaba trackeado, que es exactamente para lo que está.
…default
get_task, update_task y update_commitment resuelven un id sin proyecto
barriendo todos los proyectos y avisando cuando el prefijo matchea en más de
uno. Ese aviso no salía nunca si uno de los que matcheaban era el proyecto
default: la búsqueda lo consultaba primero y devolvía ahí mismo.
La preferencia iba por `resolveProject(projects)`, que contesta con el scope del
turno O, si no hay, con el default. No son lo mismo. "Mi propio proyecto" es una
respuesta y puede ganarle a la ambigüedad; el default es un fallback que nadie
eligió, y preferirlo es exactamente la moneda al aire que el módulo existe para
evitar — encima en update_task, que escribe en la historia del proyecto
equivocado. Ahora sólo gana un scope real (`projects.current()`, el que pone
scopeProjects); sin scope, el default se barre como cualquier otro.
El test que lo agarra es "get_task names both projects when an id is ambiguous
instead of picking one" (tests/task-edit-tools.test.js): fallaba el 100% de las
veces que llegaba a correr, pero llegaba una de cada 36, porque esperaba que dos
ids al azar compartieran tres caracteres y `shortId("t")` gasta dos en el
prefijo `t_`. Las otras 35 no afirmaban nada y daban verde, así que un test que
falla siempre se leía como intermitente. Ahora la colisión se fabrica.
…otro tests/exec-code-session.test.js buscaba un puerto libre con listen(0), lo cerraba, y volvía a bindear ese número más tarde. En el hueco el kernel le puede dar el mismo puerto a OTRO proceso de la suite, y el rebind no falla cuando pasa: `app.listen(0)` bindea el wildcard `[::]`, y un bind específico a 127.0.0.1 del mismo puerto convive con él sin EADDRINUSE. El socket específico gana todas las conexiones de loopback, así que las requests del otro archivo llegaban acá y volvían contestadas por este stub — un fallo que cae en un archivo que nadie tocó y que corriendo solo no se reproduce nunca. Ahora bindea primero y lee el puerto del socket que realmente tiene. Los dos archivos que aparecieron como víctimas bindean 127.0.0.1 en vez del wildcard: dos binds específicos al mismo puerto chocan con EADDRINUSE, que es ruidoso y no se deja secuestrar. thread-faces es el caso que quedó capturado — pidió 200 y le contestaron 401, y su app se construye con `token: ""`, así que no monta muro de auth y no puede producir uno. engines-test-endpoint además parsea el cuerpo él mismo: `res.json()` sobre una respuesta que no es JSON tira "Unexpected end of JSON input" desde adentro de undici, sin nombrar el status ni la ruta. La próxima vez que pase, el error dice qué contestó y con qué headers.
…a red `app.listen(0)` sin host bindea el wildcard `[::]`. Los 33 archivos que lo hacían construyen su app con `token: ""`, y con la cadena vacía `buildApi` no monta muro de auth — o sea que cada corrida de la suite dejaba decenas de servers con la API de APX sin autenticar, alcanzables desde la LAN y el tailnet mientras duraba el test. Ahora bindean 127.0.0.1. Lo segundo es el secuestro entre archivos: sobre un wildcard, otro proceso puede bindear `127.0.0.1:<el mismo puerto>` sin EADDRINUSE, y el socket específico gana toda conexión por loopback, así que un archivo termina contestando las requests de otro. Dos binds específicos chocan ruidosamente, que es lo que se quiere. El disparador concreto ya se había cerrado en f251a49; esto es defensa en profundidad, no un bug abierto. Pasar el host destapó un bug latente en tests/mobility-api.test.js: sus cinco casos leían `server.address().port` en la línea siguiente al listen, sin esperar el evento `listening`. Andaba de casualidad, porque un listen(0) sin host bindea en el acto; con host, listen difiere por dns.lookup y `address()` es null. Los cinco van ahora por un helper que espera, como el resto de la suite. AGENTS.md (regla 1) y rules/testing.md enseñaban el patrón sin host — que es de donde salieron las 33 copias — así que quedan corregidos en el mismo cambio, con el porqué y con la regla de no sondear un puerto libre para bindearlo más tarde. Verificado: test:ci completo, 2814 de 2815, skipped 0, todo 0. El único rojo es tests/project-rail-fit.test.js, que lee el fuente del rail y falla por una edición sin commitear de otro agente en el mismo árbol — no entra acá.
## [1.110.1](agentprojectcontext/apx@v1.110.0...v1.110.1) (2026-09-14) ### Bug Fixes * **tasks:** un id ambiguo se dice aunque uno de los proyectos sea el default ([2c276d4](agentprojectcontext@2c276d4))
El rail medía el alto disponible en ranuras de un tamaño y dibujaba dos cosas de otro: un proyecto son 55px porque lleva su nombre abajo, y el "+" y el "+N" son 40px porque no. Reservaba 55 para cada uno de esos dos, o sea 30px al pepe — justo lo que solía faltar para que entrara un proyecto más, y el hueco muerto que quedaba arriba del engranaje. Ahora la cuenta usa los dos tamaños reales, medidos de una regla escondida con las dos formas, y pregunta aparte si entran TODOS sin bucket (que es el caso más barato). Con 9 proyectos y 1000px de alto entran 5 en vez de 4 y sobran 8px en lugar de 75.
"Canales 8 de 12" y "Proyectos 9 de 10" no entraban juntos en un rail de
288px y el segundo se caía a otra línea. Dos cambios, ninguno de ellos
elegir palabras más cortas a mano: el contador pasa a ser una razón
("9/12"), que son los mismos tres glifos en cualquier idioma — "of" / "de"
/ "von" era literalmente la diferencia entre uno y dos renglones — y la
píldora deja de ser rígida.
Cuando aprieta, lo que cede es la ETIQUETA: trunca con "…" mientras el
ícono y el número, que es lo que se lee sin abrir el menú, no ceden nunca.
Con etiquetas alemanas largas siguen los dos en la misma línea y los dos
contadores enteros.
Abrir un chat son dos pasos — blanquear el panel y pedir el historial — y entre uno y otro la conversación es indistinguible de una vacía. Así que mientras llegaban sus 47 mensajes, el panel afirmaba "todavía no hay mensajes": contestaba la pregunta antes de saber la respuesta, y casi siempre mal. `useChat` expone `loading`, prendido desde que elegís el chat hasta que aterriza el fetch, y sólo en una apertura de verdad: una recarga silenciosa del chat que ya estás mirando no lo toca, o te parpadearía el spinner solo. El cartel de vacío queda para cuando ya se sabe que está vacío.
…ue mientras se escribe Una fila es un renglón sobre una conversación y sólo sabía contestar una pregunta: qué dijo el AGENTE. Entonces un chat al que le acabás de escribir mostraba una respuesta de hace una hora, y uno que todavía no te contestó decía "(sin respuestas todavía)" arriba del mensaje que mandaste hace treinta segundos — que se lee como un mensaje que nunca llegó. Son dos preguntas distintas y ahora son dos campos. `preview` sigue siendo la RESPUESTA, intacto: es lo que lee una notificación y contra lo que se mide "acá hay algo nuevo" (lib/notify.ts, lib/chat-read.ts), así que no se mueve por nada que no sea una respuesta. `last_message` es el último renglón, lo haya escrito quien lo haya escrito, y es lo que la fila imprime; las filas de a2a y grupo ya lo traían adentro de su preview, con el autor puesto. Las filas de tool son trabajo, no renglones: no cuentan para ninguno de los dos. Cuando el último es tuyo la fila lo marca "Vos:", salvo en los canales donde el rol "user" puede ser de otro (WhatsApp, Telegram) — ahí la fila ya lleva el nombre de la persona y decir "Vos" sería mentir sobre quién habló. Y mientras se está escribiendo la respuesta, la fila la sigue: el registro de actividad (lib/chat-activity.ts) acumula los deltas de la misma suscripción que ya alimentaba el spinner. Sólo texto — las tools viajan como frames `event` y el nombre de una tool no es un renglón que alguien lea desde una lista — y el recorte se da vuelta, así que se ve la COLA, que es la parte que se mueve. En un cuadro RTL con la bidi aislada, o el punto final de la oración se va al otro extremo y es lo primero que se corta.
…elve al chat cuando termina
Dejar trabajo corriendo existía a medias: `send_to_agent({background})`
dejaba a un agente esperando a OTRO AGENTE, y eso era lo único que el panel
de trabajo en segundo plano sabía mostrar. Un comando no tenía camino.
`run_shell` espera a que termine y lo mata a los 600 s como techo duro, así
que un agente al que le pedís trece reels tenía dos opciones y las dos son
malas: tener el turno abierto veinte minutos sin decir nada, o comerse el
SIGTERM a los diez y reportar lo que quedó en el buffer. Manu, mirando el
chat donde no pasaba ninguna de las dos: "no te veo ejecutar tools que estén
haciendo reels".
Ahora `run_shell({ background: true })` abre un job de verdad — `kind:
"shell"` en el mismo store, el mismo muro de tres, el mismo panel, el mismo
cortar. Lo que imprime el comando vuelve a la ficha mientras corre (frames
`progress` que el cliente parchea sin re-fetch, porque un render que imprime
todos los segundos sería una request cada dos, por panel, por dispositivo), y
la salida entera va a un log al lado del registro.
EL DESPERTAR NO PUEDE SER EL DEL a2a. Ese se entrega escribiendo COMO EL PEER
(`from: job.to`), y un comando no tiene peer: inventarle uno mete una cara
falsa en la bandeja, y peor, despierta al agente bajo etiqueta a2a — "no le
avises al dueño, relayeá al orquestador" — arriba de un render que pidió el
dueño, en el chat que el dueño está leyendo. Así que un job de shell se
acuerda de dónde salió (`origin`) y el despertar vuelve AHÍ, como un turno
normal, con sus tools y su historia. `deliverWake` rechaza ahora todo lo que
no sea a2a ANTES de reclamar la entrega: reclamar sin poder entregar
bloqueaba el despertar que sí iba a llegar.
Escucha el bus en vez de que lo llamen. Tres cosas terminan un job — el
proceso sale, el dueño corta, el reconciler lo cosecha después de un restart
— y las tres ya anuncian el final con el frame que dibuja el panel. Un
camino para las tres, en vez de tres lugares que se tienen que acordar de
despertar a alguien; `claimWake` sigue siendo el exactamente-una-vez.
`detached: true` compra dos cosas y cobra una. Compra el kill de grupo (sin
él, matar `sh -lc "ffmpeg …"` mata la shell y deja ffmpeg corriendo: el panel
dice detenido y el ventilador dice otra cosa) y que un render sobreviva a un
`apx restart`, que acá pasa varias veces por hora. Cobra que dejamos de
mirarlo: por eso el `lost` del reconciler no dice "se perdió el trabajo" —
dice que el proceso puede haber seguido y terminado, y que los archivos en
disco son la verdad.
El turno de chat sale de api/exec.js a host/daemon/agent-chat-turn.js. El
despertar tenía que ser EL MISMO turno — el resumen compactado adentro del
system y no replayado, el piso de cierre para que un turno vacío no se
archive como respuesta, el registro de turno activo para que el chat abierto
lo vea pensar en vivo — y una copia de todo eso se despega a la semana de
escrita, en una superficie que nadie está mirando cuando se despega.
Al super-agente no lo despierta nadie: su hilo ES su canal, no tiene
conversación a la que volver. El job igual corre, se ve y se corta, y la tool
se lo dice en la cara en vez de prometerle un regreso que no existe.
Y se lo explicamos a todos los agentes, no sólo a la tool: la sección de
prompt ahora tiene las dos formas de dejar trabajo corriendo, más las dos
reglas que faltaban — no esperar a mano (nada de `sleep`, de reintentar para
ver si terminó, de un turno "dejame chequear") y que lo que lanzás se ve en
el panel del dueño, así que lanzarlo ES mostrar el trabajo.
…viven Una fila de la bandeja tiene tres renglones y las marcas de estado vivían en el tercero, pegadas al final del mensaje — que es justo el renglón que quiere todo el ancho que haya. El de arriba, el de proyecto y canal, termina en dos chips y tiene la mitad de la fila libre. Manu: "por el espacio que tenemos el loading podría ir al final de todo en la línea de proyecto y canal, y el texto completo sin cortar". Así que suben ahí, alineadas a la derecha, y el mensaje se queda con el renglón entero: lo que se recorta ahora lo recorta el ancho del rail y nada más. Y dejan de turnarse. Eran tres hechos distintos — un turno escribiéndose, trabajo que quedó corriendo, algo sin leer — peleando por un solo casillero de 12px, donde el más ruidoso borraba al otro: con un turno en vivo el punto azul no existía, y un peer diez minutos adentro de un job tapaba lo que no habías leído. Ahora son tres marcas independientes, cada una con su propio ancho animado desde cero, una al lado de la otra. Manu: "el punto azul con la señal de proceso segundo plano podrían ir doble (ambas a la vez)". El mismo componente lo usa el rail de Chat del proyecto, donde ya estaba al final de la línea de metadatos: ahí no se mueve, sólo gana el poder mostrar dos.
Editar tu propio turno abría una tira de dos renglones con scroll adentro, sin
importar si el mensaje tenía cuatro palabras o cuatro líneas. Dos cosas
distintas, las dos de la misma caja.
El alto se calculaba contando SALTOS DE LÍNEA del borrador
(`draft.split("\\n").length`), y un mensaje largo escrito de corrido — que son
casi todos — tiene uno solo. Ahora mide el contenido de verdad: piso de dos
renglones completos, techo de diez o el 45% del panel que la contiene (lo que
sea menor), y recién ahí scrollea.
El ancho era peor y más invisible: la columna del usuario es shrink-to-fit y el
ancho intrínseco de un textarea son ~20 caracteres, así que la caja salía
angosta por definición. Mientras se edita, la columna toma el ancho que tiene
permitido — el mismo del globo — y se va el max-w duplicado que la dejaba en el
85% del 85%.
La lógica de crecer ya existía dentro de ChatInput; sale a `hooks/useAutoGrow`
y la usan los dos, así el composer y el editor no se separan con el tiempo. El
piso ahora incluye el padding, que es lo que hace que `minRows: 2` signifique
dos renglones de verdad: el composer descansa 4px más alto.
La mitad a2a ya estaba en "Don't wait for an answer you don't need yet". Un comando largo es la misma frase con otro laburante, así que va en la misma sección y no en una skill nueva: un agente que ya sabe dejarle trabajo a un peer no tiene que aprender una segunda idea. Lo que agrega es lo que una descripción de tool no puede: que trece reels con un muro de tres son una COLA (lanzás tres y lanzás el siguiente cuando te despierta cada uno) y no un `&&` de trece, donde una falla en el 4 se lleva los otros nueve y no sabés cuáles corrieron; que `exit 0` no es "salió bien" —un render puede salir 0 sin escribir nada, así que se mira el archivo antes de decir que está—; que el log entero está en disco; y que `lost` significa DESCONOCIDO, no fallado, porque el proceso sigue vivo cuando el daemon se reinicia. El mecanismo en sí sigue estando en el prompt base de todos los agentes, y a propósito: una skill hay que salir a buscarla, y nadie busca documentación sobre una capacidad que no sabe que tiene.
Son contratos sobre el TEXTO del fuente, que es lo que los hace baratos y también lo que los deja atrás cuando el código se mueve. Tres estaban rotos y `test:ci` no pasaba: - El techo del composer se mudó a `hooks/useAutoGrow` cuando el editor de mensajes pasó a compartirlo. El test ahora mira la regla donde vive, y además exige que chat-input NO se guarde una segunda copia de la cuenta. - La marca de la bandeja se mudó un renglón para arriba, a la línea de tags. El test seguía buscándola pegada al preview — y con un texto (`no_reply_yet`) que ya no existía desde que la fila pasó a imprimir el último mensaje. Se actualiza la posición y se agrega lo nuevo: las tres marcas son independientes, ninguna tapa a la otra. - El rail de proyectos dejó de contar slots uniformes y ahora mide dos alturas con probes y despeja cuántos ítems entran; el test todavía pedía `const forItems = slots - 1`.
…abla tu idioma Armar un proyecto desde el panel tenía tres agujeros, y los tres se veían recién cuando ya era tarde. EL PERFIL ERA GLOBAL Y NADA MÁS. `/projects/:pid/profile` (GET/POST/DELETE) le da a un proyecto el suyo: qué corre, qué podría correr, y pararlo. Activar un paquete de super-agente sobre un proyecto ahora es un 400 y no un 500 — el refusal por scope (`belongs to`) es un error del que pide, no del daemon. La tarjeta vive en Config, y el Overview avisa arriba de todo cuando un proyecto dice ser una cosa y corre como otra: los números de abajo están en cero JUSTAMENTE por eso. UN PAQUETE SE PRESENTABA EN INGLÉS. Un profile ya podía traducir su prompt (PROFILE.es.md) y cada uno de sus settings (config.schema.es.json), pero su propio nombre y descripción no tenían capa: el panel en español listaba el paquete company con una oración en inglés debajo y ninguna key para arreglarlo, porque esa cadena es data del paquete, no copy de la app. Ahora hay `profile.es.json` — sólo strings de display; id, version, scope, provides y requires siguen en el manifest base, que es la única fuente de verdad del comportamiento. La bóveda de agentes tiene el mismo corte y por la misma razón, con una frontera que importa: el TEMPLATE queda en inglés. Su `role` y su `description` se escriben en el frontmatter al instalar y los dos llegan al modelo, así que traducir esa copia cambiaría lo que el agente ES. El overlay (`core/apc/vault-strings.js` + `assets/agent-vault.es.json`) contesta otra pregunta: qué LEE quien está decidiendo si lo instala. El nombre, la descripción y el "qué hace esto" de un pack sí se traducen enteros — eso no lo lee nadie más que el lector. Y DOS COSAS QUE ROMPÍAN LA INSTALACIÓN: - `writeAgentFile` no creaba su directorio. `installPack` llamaba a `ensureAgentDir` en la línea siguiente, así que instalar un equipo en un proyecto que nunca había tenido un agente tiraba ENOENT en el primer miembro — con las seis áreas del pack ya escritas. El proyecto quedaba pareciendo una empresa (Structure lleno de áreas) sin agentes ni roles, y nada decía qué había pasado. - Importar un template suelto iba derecho al disco sin pasar por el bautismo: el mismo template que a través del diálogo de equipo se convierte en "Briar · Chief Financial Officer" quedaba como "cfo / cfo" — y sin cara — desde la tarjeta de al lado. Un template que sí trae persona propia la conserva. Los packs además traen sus `areas`, y cada template su `area`, así que un equipo se instala ya ordenado en la estructura en vez de caer plano.
…os nombra enteros
El picker del composer —el mismo que elige el modelo de compactación en
Settings › Memory— armaba su lista desde `GET /engines`, que devuelve los
adapters que trae el build, no los proveedores que el usuario configuró. De
esa confusión salían tres cosas, ninguna con un error a la vista:
• Sondeaba con `{engine}` solo. El daemon busca la api_key por SLUG de
proveedor, y `base_url` es la única forma de decir DÓNDE está el server —
así que a Ollama se le preguntaba en 127.0.0.1 mientras corre en otra
máquina. Lista vacía, 502 tragado, nada que mostrar.
• Sondeaba proveedores apagados, y jamás podría sondear uno configurado
bajo un slug propio.
• Prefijaba `<engine>:` sólo cuando el id no traía `:` — y todos los de
Ollama traen uno. `gemma3:4b` se ofrecía entero y volvía leído como el
proveedor "gemma3", que no es nada: elegirlo no podía funcionar.
Ahora hay un catálogo (components/agents/modelCatalog) sobre una sonda
(hooks/useProviderModels), compartidos por todo el panel: sólo los
proveedores activos, cada uno preguntado por su nombre y su dirección, con
memo por (slug, engine, base_url) y la última lista en caché como piso. Los
known_models curados de presets.js quedan siendo lo que el archivo dice que
son: el fallback de cuando no contesta.
useOllamaModels era la única copia que lo hacía bien y pasa a ser un wrapper
del genérico, para que no queden dos lugares donde la respuesta pueda
divergir. El picker pide el catálogo recién cuando lo abrís.
# [1.111.0](agentprojectcontext/apx@v1.110.1...v1.111.0) (2026-09-14) ### Bug Fixes * **web:** el selector ofrece los modelos de cada motor encendido, y los nombra enteros ([8fccb8d](agentprojectcontext@8fccb8d)) * **web:** la caja de editar un mensaje crece con lo que estás editando ([b2f32c6](agentprojectcontext@b2f32c6)) * **web:** la columna de proyectos llega hasta el engranaje ([53e4e3a](agentprojectcontext@53e4e3a)) * **web:** las marcas de la fila suben a la línea de tags, y ahora conviven ([2496b33](agentprojectcontext@2496b33)) * **web:** los dos filtros de la bandeja entran en un renglón ([e0c5bba](agentprojectcontext@e0c5bba)) * **web:** un chat que todavía está cargando no dice que está vacío ([904598b](agentprojectcontext@904598b)) ### Features * **agents:** un comando largo puede quedar corriendo, y el agente vuelve al chat cuando termina ([3d38828](agentprojectcontext@3d38828)) * **projects:** un proyecto elige su perfil, y la bóveda de agentes habla tu idioma ([84076b1](agentprojectcontext@84076b1)) * **web:** la fila de la bandeja dice lo último que se dijo, y lo sigue mientras se escribe ([a48579a](agentprojectcontext@a48579a))
…string JSON
Anoche reels dejó dos renders corriendo, los dos terminaron, los dos
despertaron al agente en su chat — y las dos veces el turno se murió antes de
contestar una palabra. Desde afuera se leyó como un agente que no hizo nada
dos veces seguidas.
Fue esto:
ollama 400: {"error":"Value looks like object, but can't find closing '}' symbol"}
Ollama quiere `function.arguments` como OBJETO. OpenAI lo quiere como string
JSON, y APX manda lo que le devolvió el motor que produjo la llamada, así que
un string llega acá por dos caminos de lo más normales: un turno que se cayó a
otro motor en el medio del loop (zen → ollama), y `extractBareFunctionCalls`,
que stringifica a propósito.
Y Ollama no rechaza el string de una: lo parsea con un scanner que va
buscando la llave que cierra. Por eso `{"command":"ls"}` pasa y un comando que
lleva su propio JSON adentro no — el de reels escribía un `segments.json` con
echo. Necesita un argumento grande para mostrarse, que es por qué todas las
llamadas chicas del mismo chat funcionaron todo el día, y por qué reventaba
recién en la SEGUNDA vuelta del loop: la que vuelve a mandar la llamada.
Se normaliza en el borde, que es donde vive el problema: el loop no tiene por
qué saber qué formato quiere cada proveedor. Un string que no parsea queda en
`{}`, igual que hace el propio loop con uno ilegible — perder los argumentos
de una llamada que ya corrió es peor registro que mandarlos, y perder el turno
entero es peor que las dos cosas.
Dos cosas más del mismo incidente:
- El despertar se reintenta una vez. Nada re-entrega un aviso: el reconciler
sólo barre los jobs abiertos, así que un turno que explota es el final del
asunto. Lo que no se pierde en ningún caso es el texto, que ya quedó
archivado en la conversación: el agente pierde la chance de actuar solo, no
la información.
- El aviso deja de pintarse como un mensaje del dueño. Es un texto escrito
para el modelo — el comando, el exit code y la cola de salida, en inglés — y
en una burbuja verde se leía como algo que Manu había pegado en su propio
chat ("¿qué es esto? no sé por qué lo veo"). Ahora es un renglón con el
resultado, que se despliega si querés ver lo que recibió el agente. El
desenlace viaja como CAMPOS en el meta del turno, no se re-parsea de la
prosa.
…y spawn no los encuentra
`_spawn.js` llamaba a `spawn(command)` de node:child_process. En Windows npm
instala los CLI de agentes como shims `.cmd`, y CreateProcess no puede ejecutar
un archivo batch: el spawn muere con ENOENT aunque el mismo comando ande bien en
la terminal.
Eso dejaba afuera a todos los runtimes instalados por npm — codex, gemini-cli,
qwen-code, opencode, aider, cursor-agent — y tambien a detect.js, que prueba los
binarios a traves de este mismo helper y por eso los reportaba como no
instalados.
cross-spawn resuelve el shim por PATHEXT y lo re-rutea por cmd.exe con el
escapado que corresponde. En POSIX delega directo en child_process.spawn, asi
que ese camino no cambia.
Medido en Windows 11 / Node 24.13.1:
runProcess({command:"codex"}) antes -> exitCode -1, "spawn codex ENOENT"
ahora -> exitCode 0, "codex-cli 0.154.0"
Y `apx run --runtime codex` pasa de no producir nada a devolver la respuesta del
modelo, con su session id capturado para poder reanudar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
|
¡Gracias por el PR y por el análisis tan completo! Lo estamos mirando — la solución con |
Contributor
|
¡Gracias de nuevo! Lo revisamos y el arreglo es correcto:
Apenas esté, aprobamos el run de CI (queda en espera por venir de un fork). El crash de salida lo seguimos en el issue aparte. 🙌 |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
El problema
src/core/runtimes/_spawn.jsusaspawn(command)denode:child_process. En Windows npm instala los CLI de agentes como shims.cmd, y CreateProcess no puede ejecutar un archivo batch: el spawn muere conENOENTaunque el mismo comando ande perfecto en la terminal.El alcance es mas grande de lo que parece, porque todo pasa por este helper:
apx run --runtime <x>no produce nada en Windows.detect.js, que prueba los binarios conrunProcessy por eso los reporta como no instalados aunque esten en el PATH.El arreglo
cross-spawnresuelve el shim por PATHEXT y lo re-rutea porcmd.execon el escapado correcto. En POSIX delega directo enchild_process.spawn, asi que ese camino queda igual. Es una API drop-in: solo cambia el import.Elegi
cross-spawnen vez de escribir la resolucion a mano porque el escapado paracmd.exees justo donde estos arreglos se rompen, y los prompts que pasan por aca llevan comillas y saltos de linea. Ademas ya estaba en el arbol de dependencias (lo traenexecayyargs), asi que no agrega peso real — solo lo declara.Medicion
Windows 11 Pro 26200, Node 24.13.1, apx 1.110.0:
Y el camino completo:
Que probe y que no
runProcessdirecto, el adaptador de codex, yapx run.node --checksobre el archivo, y elpackage.jsonparsea.npm run preflightni la suite de tests: no tengo el arbol de dependencias instalado en el clon. Los tests detests/runtimes*.test.jsno referencianchild_processnirunProcess, asi que no deberia haber sorpresas, pero no lo puedo afirmar.child_process.spawnsin tocar nada, pero conviene que lo confirme CI.Nota aparte
Con esto arreglado aparece un segundo bug, independiente, que hasta ahora quedaba tapado:
apx runcompleta el trabajo, imprime la respuesta del modelo, y despues crashea consaliendo con codigo 127 pese a haber andado bien. Pasa igual con el daemon ya levantado, asi que no es el autostart. No lo toco en este PR — lo reporto como issue aparte.
🤖 Generated with Claude Code