@hncode/hncode 0.5.1 → 0.5.4
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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/web-live.js
ADDED
|
@@ -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
|
+
}
|