@hncode/hncode 0.5.0 → 0.5.2

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.
Files changed (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
@@ -0,0 +1,250 @@
1
+ // Waking a saved session so the daemon can run it with no terminal anywhere.
2
+ //
3
+ // This is what replaces the blanket 409 the daemon used to answer every action with: a saved
4
+ // session was readable and nothing else, which makes the web UI a viewer rather than a client.
5
+ //
6
+ // WHAT IT NEEDS AND WHERE IT COMES FROM
7
+ // -------------------------------------
8
+ // A live session is `state` + `cfg` + `session` plus two objects that already exist:
9
+ //
10
+ // SessionHost the stream buffer, the transcript recorder, the message shape
11
+ // SessionCore submit / dispatch / interrupt / queue
12
+ //
13
+ // The daemon can build all four itself. What it CANNOT build is `runAgent` — the 1024-line turn
14
+ // loop, which still lives inside the TUI's closure and reaches for `state.scroll`, `beginTurn`
15
+ // and the git badge. That one function is injected by the TUI module, which is why the daemon
16
+ // asks for it rather than importing it at the top.
17
+ import * as sess from './session.js';
18
+ import { resolveConfig } from './config.js';
19
+ import { SessionHost } from './session-host.js';
20
+ import { SessionCore } from './session-core.js';
21
+ import { HEADLESS_VIEW } from './session-view.js';
22
+
23
+ const IDLE_MS = 30 * 60 * 1000;
24
+
25
+ /** Live sessions by id, so a second request does not build a second agent over one conversation. */
26
+ const live = new Map();
27
+
28
+ const fresh = (e) => !!e && (Date.now() - e.at) < IDLE_MS;
29
+
30
+ /** Forget sessions nobody has touched. Returns how many were dropped. */
31
+ export function sweepIdle(now = Date.now()) {
32
+ let n = 0;
33
+ for (const [id, e] of live) if (now - e.at > IDLE_MS) { live.delete(id); n++; }
34
+ return n;
35
+ }
36
+
37
+ export function liveEntry(id) {
38
+ const e = live.get(String(id));
39
+ return fresh(e) ? e : null;
40
+ }
41
+
42
+ export function liveSessionIds() {
43
+ return [...live].filter(([, e]) => fresh(e)).map(([id]) => id);
44
+ }
45
+
46
+ /** Drop a session from memory. Its transcript stays on disk. */
47
+ export function sleepSession(id) {
48
+ return live.delete(String(id));
49
+ }
50
+
51
+ /**
52
+ * The turn loop, supplied by whoever built this module's caller.
53
+ *
54
+ * The daemon cannot import `tui.js` for it without loading the entire renderer, and more to the
55
+ * point the loop is not READY to move: it is 1024 lines woven through the paint path. Injecting
56
+ * it keeps the seam honest — when it does move, only this assignment changes.
57
+ */
58
+ let runnerFactory = null;
59
+
60
+ /** Register the source of turn loops. Called once by the TUI module on import. */
61
+ export function setRunnerFactory(fn) {
62
+ runnerFactory = typeof fn === 'function' ? fn : null;
63
+ }
64
+
65
+ /**
66
+ * Wake `id`, or hand back the entry already holding it.
67
+ *
68
+ * Never throws: every failure here is something a browser should be told, not a crashed daemon.
69
+ */
70
+ export function wakeSession(id, { onEvent, workspace } = {}) {
71
+ const key = String(id || '');
72
+ if (!key) return { ok: false, error: 'no session id' };
73
+
74
+ const existing = liveEntry(key);
75
+ if (existing) { existing.at = Date.now(); return { ok: true, entry: existing, reused: true }; }
76
+
77
+ let session;
78
+ try { session = sess.loadSession(key); } catch (e) {
79
+ return { ok: false, error: `could not read session ${key}: ${(e && e.message) || e}` };
80
+ }
81
+ if (!session) return { ok: false, error: `no session ${key}` };
82
+
83
+ // The session's OWN workspace: a conversation about one project must not start reading another.
84
+ const cwd = workspace || session.workspace || process.cwd();
85
+
86
+ let cfg;
87
+ try { cfg = resolveConfig({ workspace: cwd }); } catch (e) {
88
+ return { ok: false, error: `could not resolve config: ${(e && e.message) || e}` };
89
+ }
90
+ // The session records which model it was talking to. A config change since then must not
91
+ // silently move an old conversation onto a different one.
92
+ if (session.model) cfg.model = session.model;
93
+
94
+ const state = makeHeadlessState(cfg, session, cwd);
95
+ const view = headlessViewFor(session, state);
96
+ const host = new SessionHost({ state, cfg, session, view });
97
+ const core = new SessionCore({ state, cfg, session, view, host });
98
+
99
+ const entry = {
100
+ id: key, session, cfg, state, host, core, view, at: Date.now(),
101
+ listeners: new Set(),
102
+ // The runner is attached below; a first action waits on this so it cannot race the wiring.
103
+ ready: null,
104
+ };
105
+
106
+ core.onEvent((e) => {
107
+ entry.at = Date.now();
108
+ for (const fn of entry.listeners) { try { fn(e); } catch { /* one bad socket */ } }
109
+ if (onEvent) { try { onEvent(e); } catch { /* as above */ } }
110
+ });
111
+
112
+ entry.ready = attach(entry);
113
+
114
+ live.set(key, entry);
115
+ return { ok: true, entry };
116
+ }
117
+
118
+ /**
119
+ * Build the state a headless session runs on.
120
+ *
121
+ * Deliberately NOT `makeState` from tui.js: that seeds twenty fields the renderer needs (panel
122
+ * sizes, drag flags, hover state, the composer) and none of which a headless run reads. The
123
+ * fields below are the ones a turn actually touches.
124
+ */
125
+ function makeHeadlessState(cfg, session, cwd) {
126
+ const effort = session.effort || cfg.effort || (cfg.reasoning ? 'on' : 'off');
127
+ return {
128
+ cfg,
129
+ session,
130
+ workspace: cwd,
131
+ cwd,
132
+ provider: cfg.provider || '',
133
+ model: cfg.model || '',
134
+ modelLabel: cfg.model || '',
135
+ mode: session.mode || 'ask',
136
+ reasoning: effort !== 'off',
137
+ effort,
138
+ chat: [],
139
+ queued: [],
140
+ running: false,
141
+ rounds: session.rounds || 0,
142
+ steps: session.steps || 0,
143
+ _stepsBase: 0,
144
+ _turnSteps: 0,
145
+ turnStart: 0,
146
+ lastTurnMs: session.lastTurnMs || 0,
147
+ titleAttempts: 0,
148
+ todos: Array.isArray(session.todos) ? session.todos : [],
149
+ tasks: {},
150
+ ctxTokens: 0,
151
+ ctxPercent: 0,
152
+ ctxMax: cfg.maxContextTokens || 512000,
153
+ tokenHistory: Array.isArray(session.tokenHistory) ? session.tokenHistory : [],
154
+ saveHistory: session.saveHistory !== false,
155
+ saveThinking: session.saveThinking !== false,
156
+ // Read by the turn loop. A session driven from a browser is never the foreground process, so
157
+ // these stay at their resting values and nothing scrolls to follow them.
158
+ scroll: 0,
159
+ agent: null,
160
+ _quit: false,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The display for a headless run: nothing is drawn, and the transcript is what gets synced.
166
+ *
167
+ * `syncChat` is the one method that must do real work — it is how the browser sees the turn — and
168
+ * everything else is the no-op it looks like.
169
+ */
170
+ function headlessViewFor(session, state) {
171
+ return {
172
+ ...HEADLESS_VIEW,
173
+ syncChat: (chat) => { if (Array.isArray(chat)) session.messages = chat.slice(); },
174
+ // The turn loop reads the window size when it wraps prose. A browser has no terminal, and
175
+ // reporting zero would divide by it.
176
+ dims: () => ({ cols: 100, rows: 30 }),
177
+ };
178
+ }
179
+
180
+ /** Ask the injected factory for the turn loop and bind it to the entry. */
181
+ async function attach(entry) {
182
+ if (!runnerFactory) {
183
+ return { ok: false, error: 'no headless runner is registered in this process' };
184
+ }
185
+ try {
186
+ const bound = await runnerFactory(entry);
187
+ entry.core.bind(bound);
188
+ return { ok: true };
189
+ } catch (e) {
190
+ return { ok: false, error: (e && e.message) || String(e) };
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Run an action against a session, waking it first.
196
+ *
197
+ * The action names and argument shapes are the same ones a live session's `/api/action` accepts,
198
+ * so the browser needs no special case — the only difference is that the first call pays for a
199
+ * resume.
200
+ */
201
+ export async function actionOn(id, name, args = [], opts = {}) {
202
+ const woken = wakeSession(id, opts);
203
+ if (!woken.ok) return { ok: false, error: woken.error, status: 404 };
204
+ const { entry } = woken;
205
+
206
+ const ready = await entry.ready;
207
+ if (!ready || ready.ok === false) {
208
+ return { ok: false, error: (ready && ready.error) || 'the session runner is unavailable', status: 500 };
209
+ }
210
+
211
+ try {
212
+ if (name === 'submit') await entry.core.submit(args[0]);
213
+ else if (name === 'dispatch') await entry.core.dispatch(args[0], args[1]);
214
+ else if (name === 'interrupt') await entry.core.interrupt();
215
+ // `setMode` lives on the CORE, not on the entry: the entry is a registry row holding
216
+ // `core`/`host`/`view`, and calling it on the row itself threw a TypeError that the
217
+ // catch below reported as a 409 — so the mode silently refused to change.
218
+ else if (name === 'setMode') await entry.core.setMode(args[0]);
219
+ else return { ok: false, error: `action "${name}" is not available for a resumed session`, status: 400 };
220
+ } catch (e) {
221
+ return { ok: false, error: (e && e.message) || String(e), status: 409 };
222
+ }
223
+
224
+ // The transcript changed, so the copy on disk is stale by definition.
225
+ try { sess.saveSession(entry.session); } catch { /* reported on the next read */ }
226
+ return { ok: true, result: undefined };
227
+ }
228
+
229
+ /**
230
+ * The live session's status, in the shape `savedSessionState` uses.
231
+ *
232
+ * `readOnly: false` is what tells the UI its actions will be accepted — the single flag that
233
+ * separates a session a browser can drive from one it can only read.
234
+ */
235
+ export function liveSessionState(id) {
236
+ const e = liveEntry(id);
237
+ if (!e) return null;
238
+ const st = e.state;
239
+ return {
240
+ readOnly: false,
241
+ status: {
242
+ running: !!st.running,
243
+ model: e.cfg.model || '',
244
+ mode: st.mode || 'ask',
245
+ rounds: st.rounds || 0,
246
+ steps: st.steps || 0,
247
+ queued: [...(st.queued || [])],
248
+ },
249
+ };
250
+ }
package/src/web.js CHANGED
@@ -33,8 +33,10 @@ const LOGIN_DELAY_MS = 400; // slows online guessing; costs a human nothi
33
33
  // own to expose it, and neither transport can drift from the other.
34
34
  const ALLOWED_ACTIONS = [
35
35
  // conversation
36
- 'submit', 'dispatch', 'interrupt', 'approve', 'answerQuestion', 'stopTask',
36
+ 'submit', 'dispatch', 'interrupt', 'approve', 'answerPlan', 'answerQuestion', 'stopTask',
37
37
  'setMode', 'shell', 'steer', 'editQueued', 'dropQueued',
38
+ // files
39
+ 'readFile', 'writeFile',
38
40
  // session settings
39
41
  'setTitle', 'getConfig', 'setConfig',
40
42
  // providers and models