neagen 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # neagen
2
2
 
3
- Your personal **networking agent**, in the terminal.
3
+ **Agentic networking**, in the terminal.
4
4
 
5
- State a need, and your Neagen remembers who you know, finds the right person
6
- across your network, and prepares a warm introduction — you review and approve
7
- every outbound. The CLI is a thin, secure client for your hosted Neagen.
5
+ State a need, and your Neagen represents you in the network: it finds the
6
+ right person, prepares the introduction, and shares only what you allow. You
7
+ review and approve every outbound. The CLI is a thin, secure client for your
8
+ hosted Neagen.
8
9
 
9
10
  ## Install & run
10
11
 
@@ -48,6 +49,7 @@ Your passkey stays the root of your identity.
48
49
  | --- | --- | --- |
49
50
  | `NEAGEN_HOST` | `https://neagen.xyz` | Server to connect to (local dev: `http://localhost:3000`) |
50
51
  | `NEAGEN_PROFILE` | _(main)_ | Named credential slot, e.g. for multiple accounts |
52
+ | `NEAGEN_VERBOSE` | _(off)_ | Set to `1` to show full tool output and reasoning. Off by default, so you see a short line per tool and a brief thinking indicator. Type `/verbose` in the app for the current state. |
51
53
 
52
54
  ## Where your session is stored
53
55
 
@@ -15,7 +15,16 @@ import {
15
15
  } from "./api.mjs";
16
16
  import { runDeviceGrantLogin } from "./device-login.mjs";
17
17
  import { decorateApprovalRequests } from "./approval-format.mjs";
18
- import { parseSseChunk } from "./turn-stream.mjs";
18
+ import {
19
+ droppedSessionFailed,
20
+ isBoundaryEvent,
21
+ neagenErrorToSessionFailed,
22
+ parseSseChunk,
23
+ readStreamChunks,
24
+ stalledSessionFailed,
25
+ TURN_STALL_MS,
26
+ unconfirmedBoundarySessionFailed,
27
+ } from "./turn-stream.mjs";
19
28
  import { subscribeInboxWakeAuto } from "./wake.mjs";
20
29
 
21
30
  const require = createRequire(import.meta.url);
@@ -81,24 +90,6 @@ function isNeagenCommitEvent(eventName) {
81
90
  eventName === "neagen.gate_declined";
82
91
  }
83
92
 
84
- function neagenErrorToSessionFailed(data) {
85
- return {
86
- type: "session.failed",
87
- data: {
88
- error: {
89
- code: data?.code ?? "turn_failed",
90
- message: data?.message ?? "Turn failed.",
91
- },
92
- },
93
- };
94
- }
95
-
96
- function isBoundaryEvent(event) {
97
- return event?.type === "session.waiting" ||
98
- event?.type === "session.completed" ||
99
- event?.type === "session.failed";
100
- }
101
-
102
93
  // Swap Eve's raw "Approve tool call: <name>" prompt for a reviewable card on
103
94
  // the events that carry approval requests. Presentation only; see approval-format.mjs.
104
95
  function decorateEventData(event, data) {
@@ -111,7 +102,9 @@ function decorateEventData(event, data) {
111
102
  return data;
112
103
  }
113
104
 
114
- async function* readProductTurnEvents(response, onProductEvent) {
105
+ async function* readProductTurnEvents(response, onProductEvent, options = {}) {
106
+ const idleMs = options.idleMs ?? TURN_STALL_MS;
107
+
115
108
  if (!response.ok) {
116
109
  let message = `HTTP ${response.status}`;
117
110
  const text = await response.text();
@@ -129,12 +122,22 @@ async function* readProductTurnEvents(response, onProductEvent) {
129
122
  const decoder = new TextDecoder();
130
123
  let buffer = "";
131
124
  let heldBoundary;
125
+ // A boundary (session.waiting/completed/failed) has been yielded to the runner.
126
+ // A held boundary is only released by `done` or a commit event, so a delivered
127
+ // boundary means the turn committed server-side. Without one the TUI keyboard
128
+ // never re-enables, so the tail synthesizes a terminal event.
129
+ let boundaryDelivered = false;
130
+ // A commit event (finalized/waiting_saved/gate_declined) reached us: the turn
131
+ // committed server-side even if its boundary or `done` never did. The tail uses
132
+ // this to prefer a benign completion over a scary failure.
133
+ let commitSeen = false;
132
134
 
133
135
  async function* handleEntry(entry) {
134
136
  const { event, data } = entry;
135
137
 
136
138
  if (event === "done") {
137
139
  if (heldBoundary) {
140
+ boundaryDelivered = true;
138
141
  yield heldBoundary;
139
142
  heldBoundary = undefined;
140
143
  }
@@ -142,14 +145,17 @@ async function* readProductTurnEvents(response, onProductEvent) {
142
145
  }
143
146
 
144
147
  if (event === "neagen.error") {
148
+ boundaryDelivered = true;
145
149
  yield neagenErrorToSessionFailed(data);
146
150
  heldBoundary = undefined;
147
151
  return;
148
152
  }
149
153
 
150
154
  if (isNeagenCommitEvent(event)) {
155
+ commitSeen = true;
151
156
  onProductEvent(event, data);
152
157
  if (heldBoundary) {
158
+ boundaryDelivered = true;
153
159
  yield heldBoundary;
154
160
  heldBoundary = undefined;
155
161
  }
@@ -164,25 +170,46 @@ async function* readProductTurnEvents(response, onProductEvent) {
164
170
  yield eveEvent;
165
171
  }
166
172
 
167
- while (true) {
168
- const { done, value } = await reader.read();
169
- if (done) break;
170
- buffer += decoder.decode(value, { stream: true });
171
- const parsed = parseSseChunk(buffer);
172
- buffer = parsed.trailing;
173
- for (const entry of parsed.events) {
174
- yield* handleEntry(entry);
173
+ let streamError;
174
+ try {
175
+ for await (const value of readStreamChunks(reader, { idleMs })) {
176
+ buffer += decoder.decode(value, { stream: true });
177
+ const parsed = parseSseChunk(buffer);
178
+ buffer = parsed.trailing;
179
+ for (const entry of parsed.events) {
180
+ yield* handleEntry(entry);
181
+ }
175
182
  }
176
- }
177
183
 
178
- if (buffer.trim()) {
179
- const parsed = parseSseChunk(`${buffer}\n\n`);
180
- for (const entry of parsed.events) {
181
- yield* handleEntry(entry);
184
+ if (buffer.trim()) {
185
+ const parsed = parseSseChunk(`${buffer}\n\n`);
186
+ for (const entry of parsed.events) {
187
+ yield* handleEntry(entry);
188
+ }
182
189
  }
190
+ } catch (error) {
191
+ streamError = error;
183
192
  }
184
193
 
185
- if (heldBoundary) yield heldBoundary;
194
+ if (boundaryDelivered) return;
195
+
196
+ // The stream ended abnormally: no boundary was released in the loop (no `done`,
197
+ // no commit). A boundary still held here was never confirmed, so releasing it
198
+ // would show success for a turn that may not have committed. Decide from what
199
+ // we saw, always synthesizing a terminal event so the runner re-enables input.
200
+ if (commitSeen) {
201
+ // Committed server-side even without a boundary/`done` back (e.g.
202
+ // gate_declined sends no session boundary). Prefer a benign completion over a
203
+ // scary failure, on a clean end and an unclean one alike.
204
+ yield { type: "session.completed", data: {} };
205
+ } else if (heldBoundary) {
206
+ yield unconfirmedBoundarySessionFailed();
207
+ } else if (streamError?.code === "turn_stalled") {
208
+ yield stalledSessionFailed();
209
+ } else {
210
+ yield droppedSessionFailed();
211
+ }
212
+ heldBoundary = undefined;
186
213
  }
187
214
 
188
215
  class ProductMessageResponse {
@@ -283,6 +310,14 @@ function modelOptions(models, activeId) {
283
310
  function createModelCommandHandler({ host, token, productClient, title }) {
284
311
  return {
285
312
  async handle(command, context) {
313
+ if (command.name === "verbose") {
314
+ const on = process.env.NEAGEN_VERBOSE === "1";
315
+ return {
316
+ message: on
317
+ ? "Verbose display is on: full tool output and reasoning are shown. Restart with NEAGEN_VERBOSE=0 neagen to hide them again."
318
+ : "Neagen shows a short line per tool and a brief thinking indicator, hiding raw tool output and reasoning. To see everything, restart with NEAGEN_VERBOSE=1 neagen.",
319
+ };
320
+ }
286
321
  if (command.name !== "model") {
287
322
  return { message: `/${command.name} is not supported in the Neagen TUI.` };
288
323
  }
@@ -406,13 +441,23 @@ export async function runProductTui({ profile } = {}) {
406
441
  process.on("SIGINT", async () => { await stopWake(); process.exit(0); });
407
442
  process.on("exit", () => void wake?.close());
408
443
 
444
+ // Non-technical Owners get a calm transcript by default: one short line per
445
+ // tool call (name + args, raw bash/file/JSON output hidden) and a brief
446
+ // thinking indicator instead of streamed chain-of-thought. NEAGEN_VERBOSE=1
447
+ // restores the full developer view. The runner captures these at construction
448
+ // and builds its renderer once, so this is a startup choice, not a live toggle
449
+ // (see the /verbose command note).
450
+ const verbose = process.env.NEAGEN_VERBOSE === "1";
451
+ const partDisplay = verbose ? "full" : "collapsed";
452
+
409
453
  await new EveTUIRunner({
410
454
  session,
411
455
  client,
412
456
  serverUrl: host,
413
457
  name: title,
414
- tools: "full",
415
- reasoning: "full",
458
+ tools: partDisplay,
459
+ reasoning: partDisplay,
460
+ subagents: partDisplay,
416
461
  assistantResponseStats: "tokensPerSecond",
417
462
  promptCommandHandler: createModelCommandHandler({ host, token, productClient: client, title }),
418
463
  availablePromptCommands: [
@@ -432,6 +477,12 @@ export async function runProductTui({ profile } = {}) {
432
477
  argumentHint: "[all|stderr|sandbox|none]",
433
478
  takesArgument: true,
434
479
  },
480
+ {
481
+ name: "verbose",
482
+ aliases: [],
483
+ description: "How to show full tool output and reasoning",
484
+ takesArgument: false,
485
+ },
435
486
  { name: "exit", aliases: ["quit"], description: "Quit", takesArgument: false },
436
487
  ],
437
488
  }).run();
@@ -6,6 +6,108 @@ function sseEvent(event, data) {
6
6
  return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
7
7
  }
8
8
 
9
+ // Default inactivity budget for a live turn stream. If no BYTES arrive from the
10
+ // server for this long, the connection is treated as stalled. The server sends
11
+ // SSE heartbeat comments (`: ping`) roughly every 15s, so a healthy but quiet
12
+ // turn keeps resetting this budget without emitting any parsed event.
13
+ export const TURN_STALL_MS = 90000;
14
+
15
+ export class TurnStallError extends Error {
16
+ constructor(message = "Turn stream stalled.") {
17
+ super(message);
18
+ this.name = "TurnStallError";
19
+ this.code = "turn_stalled";
20
+ }
21
+ }
22
+
23
+ export function isBoundaryEvent(event) {
24
+ return event?.type === "session.waiting" ||
25
+ event?.type === "session.completed" ||
26
+ event?.type === "session.failed";
27
+ }
28
+
29
+ export function neagenErrorToSessionFailed(data) {
30
+ return {
31
+ type: "session.failed",
32
+ data: {
33
+ error: {
34
+ code: data?.code ?? "turn_failed",
35
+ message: data?.message ?? "Turn failed.",
36
+ },
37
+ },
38
+ };
39
+ }
40
+
41
+ // Synthesized boundary for a turn whose stream went quiet past the watchdog.
42
+ export function stalledSessionFailed() {
43
+ return {
44
+ type: "session.failed",
45
+ data: {
46
+ error: {
47
+ code: "turn_stalled",
48
+ message: "The connection to your Neagen stalled with no reply for a while. Your message may still be processing, so just send it again.",
49
+ },
50
+ },
51
+ };
52
+ }
53
+
54
+ // Synthesized boundary for a stream that ended (cleanly or with a network error)
55
+ // without ever delivering a terminal boundary event. Without this the TUI runner
56
+ // would wait forever on a boundary that is never coming and freeze the keyboard.
57
+ export function droppedSessionFailed() {
58
+ return {
59
+ type: "session.failed",
60
+ data: {
61
+ error: {
62
+ code: "turn_dropped",
63
+ message: "The connection to your Neagen dropped mid-reply. Your message may still be processing, so just try again.",
64
+ },
65
+ },
66
+ };
67
+ }
68
+
69
+ // Synthesized failure for a held boundary (session.completed/waiting) that the
70
+ // stream never confirmed with a commit event or a `done`. The boundary reached
71
+ // us, but the turn was cut off before the server finished saving and billing it,
72
+ // so we must not show success. The reply may have been lost mid-save.
73
+ export function unconfirmedBoundarySessionFailed() {
74
+ return {
75
+ type: "session.failed",
76
+ data: {
77
+ error: {
78
+ code: "turn_unconfirmed",
79
+ message: "The connection to your Neagen dropped before the reply was saved. Your Neagen's reply may not have been saved, so ask again to be sure.",
80
+ },
81
+ },
82
+ };
83
+ }
84
+
85
+ // Reads raw byte chunks from a stream reader with an inactivity watchdog. Any
86
+ // chunk resets the watchdog, including SSE comment bytes that parse to no event,
87
+ // so heartbeats keep a quiet turn alive. If nothing arrives for `idleMs`, the
88
+ // reader is cancelled and a TurnStallError is thrown. A read that rejects (a
89
+ // mid-stream network error) is re-thrown as-is after cancelling.
90
+ export async function* readStreamChunks(reader, { idleMs = TURN_STALL_MS } = {}) {
91
+ while (true) {
92
+ let timer;
93
+ const idle = new Promise((_resolve, reject) => {
94
+ timer = setTimeout(() => reject(new TurnStallError()), idleMs);
95
+ timer.unref?.();
96
+ });
97
+ let result;
98
+ try {
99
+ result = await Promise.race([reader.read(), idle]);
100
+ } catch (error) {
101
+ try { await reader.cancel(); } catch { /* best-effort */ }
102
+ throw error;
103
+ } finally {
104
+ clearTimeout(timer);
105
+ }
106
+ if (result.done) return;
107
+ if (result.value) yield result.value;
108
+ }
109
+ }
110
+
9
111
  export function parseSseChunk(buffer) {
10
112
  const events = [];
11
113
  const parts = buffer.split(/\n\n+/);
package/lib/wake.mjs CHANGED
@@ -5,9 +5,12 @@
5
5
  // by the app + vitest). The node entrypoints are .mjs and can't import the TS
6
6
  // path-aliased module, so the same ~40 lines live here. Keep the two in sync.
7
7
  //
8
- // The published `neagen` CLI uses ONLY subscribeInboxWakeAbly (token auth against
9
- // /api/ably-token). The fs/ws transports below are for local dev / the two-agent
10
- // harness and are inert in the remote CLI.
8
+ // The published `neagen` CLI calls subscribeInboxWakeAuto(ownerId, onWake, {
9
+ // host, bearerToken }), which routes it to subscribeInboxWakeAbly (token auth
10
+ // against /api/ably-token) since a real end user has no ABLY_API_KEY or
11
+ // NEAGEN_WAKE_TRANSPORT set on their machine. The fs/ws transports below are for
12
+ // local dev / the two-agent harness (dev-only x-neagen-owner header, which prod
13
+ // rejects) and are inert in the remote CLI.
11
14
  //
12
15
  // The wake is non-polling: fs.watch is OS-level (FSEvents / inotify), event-
13
16
  // driven. "Mail = the file system" — a wake is just an envelope appearing in the
@@ -130,6 +133,12 @@ function wakeWsUrl(host) {
130
133
  return host.replace(/\/+$/, "").replace(/^http/, "ws") + "/eve/v1/wake";
131
134
  }
132
135
 
136
+ // Give up reconnecting after this many CONSECUTIVE attempts that never reached
137
+ // OPEN (e.g. the server rejecting the upgrade with 401 — an auth wall, not a
138
+ // network blip). A successful open resets the counter, so genuine blips keep
139
+ // reconnecting forever as before; only a sustained rejection wall stops the loop.
140
+ const MAX_CONSECUTIVE_OPEN_FAILURES = 5;
141
+
133
142
  export async function subscribeInboxWakeWS(
134
143
  ownerId,
135
144
  onWake,
@@ -139,14 +148,19 @@ export async function subscribeInboxWakeWS(
139
148
  const url = wakeWsUrl(base);
140
149
 
141
150
  let closed = false;
151
+ let gaveUp = false;
142
152
  let socket;
143
153
  let reconnectTimer;
144
154
  let backoffMs = reconnectMs;
155
+ let consecutiveOpenFailures = 0;
145
156
 
146
157
  const connect = () => {
147
- if (closed) return;
158
+ if (closed || gaveUp) return;
159
+ let openedThisAttempt = false;
148
160
  socket = new WebSocket(url, { headers: { "x-neagen-owner": ownerId } });
149
161
  socket.onopen = () => {
162
+ openedThisAttempt = true;
163
+ consecutiveOpenFailures = 0;
150
164
  backoffMs = reconnectMs; // a live connection resets the backoff
151
165
  };
152
166
  socket.onmessage = (ev) => {
@@ -167,6 +181,15 @@ export async function subscribeInboxWakeWS(
167
181
  };
168
182
  socket.onclose = () => {
169
183
  if (closed) return;
184
+ if (!openedThisAttempt) {
185
+ consecutiveOpenFailures += 1;
186
+ if (consecutiveOpenFailures >= MAX_CONSECUTIVE_OPEN_FAILURES) {
187
+ // Looks like an auth wall (e.g. prod rejecting the dev-only
188
+ // x-neagen-owner header with 401), not a blip — stop hammering it.
189
+ gaveUp = true;
190
+ return;
191
+ }
192
+ }
170
193
  // Exponential backoff (NOT interval polling): one-shot timer that doubles
171
194
  // up to the cap and resets on the next successful open.
172
195
  reconnectTimer = setTimeout(connect, backoffMs);
@@ -276,9 +299,13 @@ export async function subscribeInboxWakeAbly(ownerId, onWake, { key, authUrl, ho
276
299
  * - NEAGEN_WAKE_TRANSPORT wins when set: "fs" → fs.watch (pure-local, needs a
277
300
  * shared filesystem), "ws" → our held WebSocket to the eve wake channel,
278
301
  * "ably" → Ably managed pub/sub.
279
- * - Otherwise, if ABLY_API_KEY is set → Ably (the production default: no
280
- * always-on box of our own, Ably holds the socket).
281
- * - Otherwise → the held WebSocket.
302
+ * - Otherwise, if ABLY_API_KEY is set → Ably with the raw key (local dev /
303
+ * two-agent harness).
304
+ * - Otherwise, if `host` + `bearerToken` are given Ably via token auth
305
+ * against `${host}/api/ably-token` (the published CLI: no server-side env,
306
+ * no dev-only x-neagen-owner header, so the WS transport isn't reachable in
307
+ * prod — token-auth Ably is the only transport a real end user can use).
308
+ * - Otherwise → the held WebSocket (local dev harness, x-neagen-owner header).
282
309
  * Same (ownerId, onWake) -> { close() } contract for every transport, so callers
283
310
  * don't branch.
284
311
  */
@@ -288,6 +315,7 @@ export async function subscribeInboxWakeAuto(ownerId, onWake, options = {}) {
288
315
  if (transport === "ws") return subscribeInboxWakeWS(ownerId, onWake, options);
289
316
  if (transport === "ably") return subscribeInboxWakeAbly(ownerId, onWake, options);
290
317
  if (process.env.ABLY_API_KEY) return subscribeInboxWakeAbly(ownerId, onWake, options);
318
+ if (options.host && options.bearerToken) return subscribeInboxWakeAbly(ownerId, onWake, options);
291
319
  return subscribeInboxWakeWS(ownerId, onWake, options);
292
320
  }
293
321
 
package/neagen.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // neagen your personal networking agent, in the terminal.
2
+ // neagen: agentic networking, in the terminal.
3
3
  //
4
4
  // `neagen` open the TUI (logs you in on first run)
5
5
  // `neagen login` log in / refresh your session, then exit
@@ -40,7 +40,7 @@ function parseArgs(argv) {
40
40
 
41
41
  function printHelp() {
42
42
  process.stdout.write(
43
- `neagen ${pkg.version} your personal networking agent\n\n` +
43
+ `neagen ${pkg.version}: agentic networking, in the terminal\n\n` +
44
44
  `Usage:\n` +
45
45
  ` neagen [--profile NAME] Open the agent (logs you in on first run)\n` +
46
46
  ` neagen login Log in / refresh your session\n` +
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "neagen",
3
- "version": "0.1.6",
4
- "description": "Your personal networking agent, in the terminal. Chat, agent-mail, and files against your Neagen — passkey login, no passwords.",
3
+ "version": "0.1.7",
4
+ "description": "Agentic networking, in the terminal. Your Neagen represents you in the network: chat, agent-mail, and files, with passkey login and no passwords.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "neagen": "neagen.mjs"
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "keywords": [
33
33
  "neagen",
34
+ "agentic-networking",
34
35
  "networking",
35
36
  "agent",
36
37
  "cli",