@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The session, as a thing a non-TUI front end can own.
|
|
2
|
+
//
|
|
3
|
+
// WHY NOT MOVE THE FIELDS
|
|
4
|
+
// -----------------------
|
|
5
|
+
// `state` already IS the session state — `chat`, `queued`, `running`, `rounds`, `steps`, `todos`,
|
|
6
|
+
// `ctxTokens`, `workMsg`, `titleAttempts`. Moving them into a new object would mean rewriting the
|
|
7
|
+
// 74 references inside runAgent plus every reader in composeFrame and the panels, for no gain:
|
|
8
|
+
// the fields would live one property deeper and every line that touches them would change.
|
|
9
|
+
//
|
|
10
|
+
// So the core HOLDS the state rather than replacing it. A headless run creates its own state
|
|
11
|
+
// object and hands it to a core; the TUI hands over the one it already builds. Same code, one
|
|
12
|
+
// carrier, no churn.
|
|
13
|
+
//
|
|
14
|
+
// WHAT THE CORE ADDS
|
|
15
|
+
// ------------------
|
|
16
|
+
// Three things the state object cannot do for itself, and which a second front end needs:
|
|
17
|
+
//
|
|
18
|
+
// * `submit(text)` — start a turn, from anywhere
|
|
19
|
+
// * `dispatch(cmd, arg)` — run a slash command, from anywhere
|
|
20
|
+
// * a lifecycle — `onEvent` out, `interrupt` in, without a terminal to route through
|
|
21
|
+
//
|
|
22
|
+
// Those are exactly the 207 lines of hub actions plus the 100 of submit, which is why they are
|
|
23
|
+
// what gets extracted rather than the 1020 of runAgent.
|
|
24
|
+
import { HEADLESS_VIEW } from './session-view.js';
|
|
25
|
+
|
|
26
|
+
// The team the LEAD is on.
|
|
27
|
+
//
|
|
28
|
+
// A lead has no async identity — only teammates do — so without this every tool and command it
|
|
29
|
+
// calls would have to be handed the team name, and the model does not know to pass it. One
|
|
30
|
+
// process, one lead, one team: createTeam refuses a second.
|
|
31
|
+
//
|
|
32
|
+
// This is the ONLY copy. tools/team.js reads through here rather than keeping its own, or a
|
|
33
|
+
// team created from the keyboard would be invisible to the model.
|
|
34
|
+
let leadTeamName = null;
|
|
35
|
+
|
|
36
|
+
/** The team the lead is leading, or null. */
|
|
37
|
+
export function leadTeam() {
|
|
38
|
+
return leadTeamName;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Record it. Called when a team is created, from either the tool or the command. */
|
|
42
|
+
export function setLeadTeam(name) {
|
|
43
|
+
leadTeamName = name || null;
|
|
44
|
+
return leadTeamName;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Forget it. Called when the team is dissolved. */
|
|
48
|
+
export function clearLeadTeam() {
|
|
49
|
+
leadTeamName = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class SessionCore {
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} deps
|
|
55
|
+
* @param {object} deps.state the live state object (the TUI's, or one built for a headless run)
|
|
56
|
+
* @param {object} deps.cfg
|
|
57
|
+
* @param {object} deps.session the persisted session record
|
|
58
|
+
* @param {object} [deps.view] display adapter; HEADLESS_VIEW when there is no terminal
|
|
59
|
+
*/
|
|
60
|
+
constructor({ state, cfg, session, view = HEADLESS_VIEW, host = null }) {
|
|
61
|
+
// The session machinery. Optional so a test can build a core without one, but a real
|
|
62
|
+
// session passes it: it holds the ONE stream buffer and the transcript recorder.
|
|
63
|
+
this.host = host;
|
|
64
|
+
this.state = state;
|
|
65
|
+
this.cfg = cfg;
|
|
66
|
+
this.session = session;
|
|
67
|
+
this.view = view;
|
|
68
|
+
|
|
69
|
+
/** Host callbacks the core does not own: run a turn, run a command, stop. */
|
|
70
|
+
this._runAgent = null;
|
|
71
|
+
this._dispatch = null;
|
|
72
|
+
this._quit = null;
|
|
73
|
+
/**
|
|
74
|
+
* The object `dispatch` destructures its UI callbacks from.
|
|
75
|
+
*
|
|
76
|
+
* NOT `this.view`: the view is the seam a SESSION sees (note/err/render/pick/form), while a
|
|
77
|
+
* command is written against the host's own names — `notice`, `openPicker`, `openForm`,
|
|
78
|
+
* `sendPrompt`, `saveSession`, `quit`. Passing the view left every panel-opening command
|
|
79
|
+
* (`/help`, `/model`, `/exit`) throwing `openPicker is not a function`.
|
|
80
|
+
*/
|
|
81
|
+
this._commandHost = null;
|
|
82
|
+
/** Subscribers, for a front end that wants the turn's events as they happen. */
|
|
83
|
+
this._listeners = new Set();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Hand the core the functions only the embedding process can provide.
|
|
88
|
+
*
|
|
89
|
+
* `runAgent` stays where it is for now: it is 1020 lines woven through the TUI's paint path,
|
|
90
|
+
* and moving it in one piece is the step that cannot be verified halfway. Wiring it here means
|
|
91
|
+
* the core's interface is complete and a later step can move the implementation without any
|
|
92
|
+
* caller changing.
|
|
93
|
+
*/
|
|
94
|
+
bind({ runAgent, dispatch, quit, commandHost }) {
|
|
95
|
+
if (runAgent) this._runAgent = runAgent;
|
|
96
|
+
if (dispatch) this._dispatch = dispatch;
|
|
97
|
+
if (quit) this._quit = quit;
|
|
98
|
+
if (commandHost) this._commandHost = commandHost;
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Subscribe to turn events. Returns an unsubscribe. */
|
|
103
|
+
onEvent(fn) {
|
|
104
|
+
this._listeners.add(fn);
|
|
105
|
+
return () => this._listeners.delete(fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
emit(event) {
|
|
109
|
+
for (const fn of this._listeners) {
|
|
110
|
+
try { fn(event); } catch { /* a listener must not break the turn */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- the actions a front end may take ------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Start a turn with `text`.
|
|
118
|
+
*
|
|
119
|
+
* Rejects rather than returning an error code: the caller is an HTTP handler or a key press,
|
|
120
|
+
* and both need a throw to answer with a status or a message. `hub.call` already converts that
|
|
121
|
+
* shape, so the two agree.
|
|
122
|
+
*/
|
|
123
|
+
async submit(text) {
|
|
124
|
+
if (typeof text !== 'string' || !text.trim()) throw new Error('empty message');
|
|
125
|
+
if (!this._runAgent) throw new Error('this session has no agent runner');
|
|
126
|
+
return this._runAgent(text, {}, this.view);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Run a slash command. `cmd` may be `/name args` or just `name`. */
|
|
130
|
+
async dispatch(cmd, arg) {
|
|
131
|
+
if (!this._dispatch) throw new Error('this session has no command dispatcher');
|
|
132
|
+
const raw = String(cmd || '').trim();
|
|
133
|
+
if (!raw) throw new Error('empty command');
|
|
134
|
+
const sp = raw.indexOf(' ');
|
|
135
|
+
const name = sp === -1 ? raw : raw.slice(0, sp);
|
|
136
|
+
const rest = sp === -1 ? String(arg || '') : raw.slice(sp + 1).trim();
|
|
137
|
+
const out = await this._dispatch(name, rest, this.state, this.cfg, this.session, this._commandHost);
|
|
138
|
+
if (this.state._quit && this._quit) this._quit();
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Stop the running turn.
|
|
144
|
+
*
|
|
145
|
+
* Throws when nothing is running rather than doing nothing: a front end that reports "stopped"
|
|
146
|
+
* for a turn that had already finished is lying to the user about what happened.
|
|
147
|
+
*/
|
|
148
|
+
async interrupt() {
|
|
149
|
+
const agent = this.state.agent;
|
|
150
|
+
if (!agent || typeof agent.interrupt !== 'function') throw new Error('nothing is running');
|
|
151
|
+
agent.interrupt();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether a turn is in flight. */
|
|
155
|
+
get running() {
|
|
156
|
+
return !!this.state.running;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The queued prompts, as a plain array.
|
|
161
|
+
*
|
|
162
|
+
* A copy: the queue is drained by the turn loop, and a front end iterating the live array would
|
|
163
|
+
* see it shift under it.
|
|
164
|
+
*/
|
|
165
|
+
get queued() {
|
|
166
|
+
return [...(this.state.queued || [])];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Queue a prompt for after the current turn. */
|
|
170
|
+
enqueue(text) {
|
|
171
|
+
const t = String(text || '').trim();
|
|
172
|
+
if (!t) return false;
|
|
173
|
+
(this.state.queued = this.state.queued || []).push(t);
|
|
174
|
+
this.view.pubStatusField('queued', this.queued);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Drop every queued prompt. */
|
|
179
|
+
clearQueue() {
|
|
180
|
+
this.state.queued = [];
|
|
181
|
+
this.view.pubStatusField('queued', []);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Change the permission mode.
|
|
186
|
+
*
|
|
187
|
+
* Validated here rather than by the caller: `ask` / `yolo` / `auto` is the whole set the rest
|
|
188
|
+
* of the program understands, and a typo reaching `state.mode` would be read as a mode that
|
|
189
|
+
* denies everything.
|
|
190
|
+
*/
|
|
191
|
+
async setMode(mode) {
|
|
192
|
+
const m = String(mode || '');
|
|
193
|
+
if (!['ask', 'yolo', 'auto'].includes(m)) throw new Error(`unknown mode: ${m}`);
|
|
194
|
+
this.state.mode = m;
|
|
195
|
+
if (this.session) this.session.mode = m;
|
|
196
|
+
return m;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The permission mode in force. */
|
|
200
|
+
get mode() {
|
|
201
|
+
return this.state.mode || 'ask';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The pieces a running session needs that are not the display.
|
|
2
|
+
//
|
|
3
|
+
// WHY A HOST RATHER THAN A BIGGER CORE
|
|
4
|
+
// ------------------------------------
|
|
5
|
+
// After the display calls were routed through the view, an audit of runAgent's 1024 lines found 25
|
|
6
|
+
// remaining TUI dependencies across 14 names. Only FOUR of those genuinely need a foreground
|
|
7
|
+
// process (`state.scroll`, `dropScrollPin`, `beginTurn`, `refreshGitInfo`) — and three of the four
|
|
8
|
+
// are harmless no-ops headless. The other seven names are session machinery that happened to be
|
|
9
|
+
// written inside startTUI's closure:
|
|
10
|
+
//
|
|
11
|
+
// flushStream drains the stream buffer
|
|
12
|
+
// recordTranscriptRow appends to the persisted display history
|
|
13
|
+
// normalizeMsg its shape is what every renderer and the web UI read
|
|
14
|
+
// generateTitle names the session
|
|
15
|
+
// stopTask stops a background job
|
|
16
|
+
// runShellCommand the `!cmd` path
|
|
17
|
+
//
|
|
18
|
+
// Those belong to a session, not to a terminal. The host is where they live so both a TUI and a
|
|
19
|
+
// daemon can have them.
|
|
20
|
+
import {
|
|
21
|
+
StreamBuffer, StreamOp,
|
|
22
|
+
} from './stream-buffer.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The parts of a session that outlive any one turn.
|
|
26
|
+
*
|
|
27
|
+
* `view` decides where anything the user should see goes; everything else here is state the
|
|
28
|
+
* session carries. Constructed by the TUI with its own painters, and by the daemon with
|
|
29
|
+
* HEADLESS_VIEW.
|
|
30
|
+
*/
|
|
31
|
+
export class SessionHost {
|
|
32
|
+
constructor({ state, cfg, session, view, onEvent } = {}) {
|
|
33
|
+
this.state = state;
|
|
34
|
+
this.cfg = cfg;
|
|
35
|
+
this.session = session;
|
|
36
|
+
this.view = view;
|
|
37
|
+
this.onEvent = typeof onEvent === 'function' ? onEvent : () => {};
|
|
38
|
+
|
|
39
|
+
// One buffer for the whole session rather than one per turn: the pacing it does is about the
|
|
40
|
+
// pipe, not about the turn, and a fresh buffer per turn would drop whatever the previous one
|
|
41
|
+
// had not yet released.
|
|
42
|
+
this.streamBuf = new StreamBuffer();
|
|
43
|
+
this._recorder = null;
|
|
44
|
+
|
|
45
|
+
// Where revealed text goes. Three callbacks rather than an event, because this is not a
|
|
46
|
+
// notification — it is the append itself, and a host that only announced the text would
|
|
47
|
+
// lose every character the buffer released.
|
|
48
|
+
this.appenders = {
|
|
49
|
+
assistant: () => {},
|
|
50
|
+
reasoning: () => {},
|
|
51
|
+
settleReasoning: () => {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where `recordTranscriptRow` writes.
|
|
57
|
+
*
|
|
58
|
+
* Injected rather than imported: the recorder lives in session.js and reaches the disk, and a
|
|
59
|
+
* test (or a daemon with no store) must be able to run a session without one.
|
|
60
|
+
*/
|
|
61
|
+
setRecorder(fn) {
|
|
62
|
+
this._recorder = typeof fn === 'function' ? fn : null;
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Drain whatever the stream buffer is holding. Idempotent. */
|
|
67
|
+
flushStream() {
|
|
68
|
+
const ops = this.streamBuf.flush();
|
|
69
|
+
return this.applyStreamOps(ops);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Apply the ops the buffer hands back.
|
|
74
|
+
*
|
|
75
|
+
* The push methods RETURN the characters due right now and have already dequeued them, so
|
|
76
|
+
* dropping the return value loses that text permanently — this is the one rule the whole
|
|
77
|
+
* streaming path depends on, and it is why every push goes through here.
|
|
78
|
+
*/
|
|
79
|
+
applyStreamOps(ops) {
|
|
80
|
+
if (!ops || !ops.length) return false;
|
|
81
|
+
const a = this.appenders;
|
|
82
|
+
for (const o of ops) {
|
|
83
|
+
// Text and reasoning go to DIFFERENT appenders — that is what keeps a reasoning block
|
|
84
|
+
// from overtaking the answer that followed it, and what closes the block at exactly the
|
|
85
|
+
// point the provider closed it.
|
|
86
|
+
if (o.op === StreamOp.Text) a.assistant(o.text);
|
|
87
|
+
else if (o.op === StreamOp.Reasoning) a.reasoning(o.text);
|
|
88
|
+
else if (o.op === StreamOp.CloseReasoning) a.settleReasoning();
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Append a row to the persisted DISPLAY history.
|
|
95
|
+
*
|
|
96
|
+
* Separate from the chat log: `session.messages` is what gets sent to the model, while this is
|
|
97
|
+
* what the user saw — notices, warnings, plan cards, reasoning. `/save-history off` drops it,
|
|
98
|
+
* and a session without a recorder simply does not keep it.
|
|
99
|
+
*/
|
|
100
|
+
recordTranscriptRow(msg) {
|
|
101
|
+
if (!this.session) return;
|
|
102
|
+
if (this.state.saveHistory === false) return;
|
|
103
|
+
if (!this._recorder) return;
|
|
104
|
+
try { this._recorder(this.session, msg, this.state); } catch { /* history is not worth a turn */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The canonical shape of a message.
|
|
109
|
+
*
|
|
110
|
+
* Every renderer reads rows shaped like this, and the web UI mirrors the same shape — so it is
|
|
111
|
+
* normalised once, here, rather than at each reader.
|
|
112
|
+
*
|
|
113
|
+
* `failed` is deliberately preserved: a failed tool result must render red, and dropping it made
|
|
114
|
+
* every failure look like ordinary output.
|
|
115
|
+
*/
|
|
116
|
+
normalizeMsg(m) {
|
|
117
|
+
const msg = { ...(m || {}) };
|
|
118
|
+
if (msg.text == null && msg.content != null) msg.text = String(msg.content);
|
|
119
|
+
if (msg.content == null && msg.text != null) msg.content = msg.text;
|
|
120
|
+
if (msg.role === 'tool' && msg.failed === undefined && typeof msg.text === 'string') {
|
|
121
|
+
msg.failed = /^Error\b|^Cannot read\b|^Edit rejected/.test(msg.text);
|
|
122
|
+
}
|
|
123
|
+
return msg;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Close the pending reasoning row.
|
|
128
|
+
*
|
|
129
|
+
* A reasoning block stays `pending` while it streams and is what the renderer draws a live
|
|
130
|
+
* cursor on. Nothing in a headless run draws it, but the flag is what the transcript records
|
|
131
|
+
* — a block left pending would be read back as still running on the next resume.
|
|
132
|
+
*/
|
|
133
|
+
settleThinking() {
|
|
134
|
+
const t = [...(this.state.chat || [])].reverse().find((m) => m.role === 'thinking' && m.pending);
|
|
135
|
+
if (t) t.pending = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Add `n` to the turn's output-token estimate.
|
|
140
|
+
*
|
|
141
|
+
* Called for EVERY stream the model produces — answer, reasoning, tool arguments — because
|
|
142
|
+
* the turn is billed for all three. The provider's exact figure replaces the estimate when the
|
|
143
|
+
* `usage` event lands, so this only has to be roughly right while it is on screen.
|
|
144
|
+
*
|
|
145
|
+
* `n` may be FRACTIONAL. Streaming callers pass `estimateTokensFloat` so that a chunk smaller
|
|
146
|
+
* than four characters contributes its real share instead of a whole rounded token — see that
|
|
147
|
+
* function for why summing rounded chunks inflated the figure by a third. Only the DISPLAYED
|
|
148
|
+
* total is rounded.
|
|
149
|
+
*/
|
|
150
|
+
countOutputTokens(n) {
|
|
151
|
+
const add = Number(n) || 0;
|
|
152
|
+
if (add <= 0) return;
|
|
153
|
+
const st = this.state;
|
|
154
|
+
st.turnOutputEstimate = (st.turnOutputEstimate || 0) + add;
|
|
155
|
+
// SETTLED + ESTIMATE, not the estimate alone: a turn makes one request per step, and
|
|
156
|
+
// `usage` closes each one. Showing only the live estimate made the counter restart from
|
|
157
|
+
// zero at the start of every step.
|
|
158
|
+
st.turnOutputTokens = Math.round((st.turnOutputSettled || 0) + st.turnOutputEstimate);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether tools may touch paths outside the workspace. Read live off config. */
|
|
162
|
+
externalAllowed() {
|
|
163
|
+
return !!(this.cfg && this.cfg.allowExternal);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Write the session's live switches back to the record.
|
|
168
|
+
*
|
|
169
|
+
* Mode, plan, focus and swarm are all things a command flips mid-session and all of which a
|
|
170
|
+
* resume has to bring back. Saved here rather than at turn end so `/mode yolo` followed by a
|
|
171
|
+
* crash does not come back as `ask`.
|
|
172
|
+
*/
|
|
173
|
+
persistState() {
|
|
174
|
+
const s = this.session;
|
|
175
|
+
if (!s) return;
|
|
176
|
+
const st = this.state;
|
|
177
|
+
s.mode = st.mode;
|
|
178
|
+
s.plan = !!st.plan;
|
|
179
|
+
s.planPath = st.planPath || null;
|
|
180
|
+
s.focus = !!st.focus;
|
|
181
|
+
s.swarm = !!st.swarm;
|
|
182
|
+
s.todos = st.todos || [];
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Name the session from its first exchange.
|
|
188
|
+
*
|
|
189
|
+
* Returns the title, or '' when there is nothing to name it with. A failure returns '' rather
|
|
190
|
+
* than throwing: an unnamed session is a cosmetic loss, and the caller has a turn in flight.
|
|
191
|
+
*/
|
|
192
|
+
async generateTitle(userText, { makeTitle } = {}) {
|
|
193
|
+
if (!this.session) return '';
|
|
194
|
+
if (this.session.title) return this.session.title;
|
|
195
|
+
if (typeof makeTitle !== 'function') return '';
|
|
196
|
+
const asked = String(userText || '').replace(/\s+/g, ' ').trim().slice(0, 400);
|
|
197
|
+
if (!asked) return '';
|
|
198
|
+
try {
|
|
199
|
+
const title = await makeTitle(asked, this.cfg);
|
|
200
|
+
if (title && !this.session.title) {
|
|
201
|
+
this.session.title = String(title).slice(0, 80);
|
|
202
|
+
this.view.syncChat && this.view.syncChat(this.state.chat || []);
|
|
203
|
+
}
|
|
204
|
+
return this.session.title || '';
|
|
205
|
+
} catch {
|
|
206
|
+
// The caller counts attempts; a throw here would end a turn over a label.
|
|
207
|
+
return '';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The op kinds the buffer emits, re-exported so a consumer does not import two modules. */
|
|
213
|
+
export const HOST_STREAM_OPS = StreamOp;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The seam between a session and the thing displaying it.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// `runAgent` is 1020 lines and mixes three concerns: assembling the request, driving the agent
|
|
6
|
+
// loop, and PAINTING. The painting is what tied a session to the TUI — a session could only run
|
|
7
|
+
// in the process that drew it, so the web UI had to proxy to a live terminal and a session whose
|
|
8
|
+
// terminal had closed could never be continued.
|
|
9
|
+
//
|
|
10
|
+
// The audit that motivated this file counted the damage: of 1020 lines, only 127 places touch the
|
|
11
|
+
// TUI at all, and just 27 of those are rendering calls and 5 are modals. Everything else is
|
|
12
|
+
// session state the session would carry anyway. So the seam is real and small enough to cut.
|
|
13
|
+
//
|
|
14
|
+
// WHY A PARAMETER RATHER THAN A GLOBAL
|
|
15
|
+
// ------------------------------------
|
|
16
|
+
// The TUI passes its own painters; a headless run passes no-ops. A module-level "current view"
|
|
17
|
+
// would make two sessions in one process share whatever was set last — the same failure the
|
|
18
|
+
// teammate identity had, and the reason that one uses AsyncLocalStorage.
|
|
19
|
+
//
|
|
20
|
+
// Every method is optional at the call site via `view?.x?.()`. A view that only wants rows should
|
|
21
|
+
// not have to implement file dialogs, and a headless run must not have to stub twenty methods to
|
|
22
|
+
// run one turn.
|
|
23
|
+
|
|
24
|
+
/** A view that draws nothing. Used by headless runs and by tests. */
|
|
25
|
+
export const HEADLESS_VIEW = {
|
|
26
|
+
note: () => {},
|
|
27
|
+
err: () => {},
|
|
28
|
+
render: () => {},
|
|
29
|
+
renderSoon: () => {},
|
|
30
|
+
// The transcript. A headless run still APPENDS to it: the chat log is what gets persisted
|
|
31
|
+
// and what the web UI streams, so dropping this would lose the turn rather than merely not
|
|
32
|
+
// drawing it.
|
|
33
|
+
addChat: () => {},
|
|
34
|
+
sync: () => {},
|
|
35
|
+
pushRow: () => {},
|
|
36
|
+
patchRow: () => {},
|
|
37
|
+
syncChat: () => {},
|
|
38
|
+
pubStatusField: () => {},
|
|
39
|
+
edit: async () => null,
|
|
40
|
+
pick: async () => null,
|
|
41
|
+
form: async () => null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wrap a real TUI's painters into the shape `runAgent` expects.
|
|
46
|
+
*
|
|
47
|
+
* `deps` is what `startTUI` already owns: the hub, the frame painter, the chat log. Passing them
|
|
48
|
+
* in rather than importing them keeps this module free of the 17k-line file it would otherwise
|
|
49
|
+
* have to import — and makes the seam testable without a terminal.
|
|
50
|
+
*/
|
|
51
|
+
export function tuiView(deps) {
|
|
52
|
+
const {
|
|
53
|
+
// `hub` is NOT destructured: it is assigned long after this runs, so reading it here
|
|
54
|
+
// would freeze the null that exists now and every web push would silently no-op. Read it
|
|
55
|
+
// through `deps.hub` at the call site. The rest are plain functions that never change.
|
|
56
|
+
renderFrame, renderSoon, addChat, notice, syncWeb,
|
|
57
|
+
openEditor, openPicker, openForm, stdout,
|
|
58
|
+
} = deps;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
// A line the USER should read. In the TUI these are the transient notices; a teammate's
|
|
62
|
+
// failure that is only logged is a failure nobody sees.
|
|
63
|
+
note: (text, kind = 'info') => { try { notice(text, kind); } catch { /* the UI itself */ } },
|
|
64
|
+
|
|
65
|
+
// The same notice at ERROR level. Separate method because the TUI's `appErr(m)` is
|
|
66
|
+
// `notice(m, 'error')` — the level is not in the call, so a plain rename to `note` would
|
|
67
|
+
// quietly downgrade every failure to an ordinary message.
|
|
68
|
+
err: (text) => { try { notice(text, 'error'); } catch { /* the UI itself */ } },
|
|
69
|
+
|
|
70
|
+
// Repaint now, and soon. Two names because the call sites differ in intent: one has just
|
|
71
|
+
// changed what should be on screen, the other is mid-stream and wants to coalesce.
|
|
72
|
+
render: () => { try { renderFrame(); } catch { /* a paint failure must not kill a turn */ } },
|
|
73
|
+
renderSoon: () => { try { (renderSoon || renderFrame)(); } catch { /* as above */ } },
|
|
74
|
+
|
|
75
|
+
// Append to the transcript. This is DATA, not drawing: the chat log is what `--continue`
|
|
76
|
+
// saves and what the web UI streams, so it has to work with no terminal at all.
|
|
77
|
+
addChat: (msg) => { try { addChat(msg); } catch { /* as above */ } },
|
|
78
|
+
|
|
79
|
+
// A row that is updated in place rather than appended — a running tool call, a compaction
|
|
80
|
+
// block. Optional: a view without the notion of "the same row" simply ignores it.
|
|
81
|
+
pushRow: (row) => { try { deps.hub && deps.hub.pushRow(row); } catch { /* best effort */ } },
|
|
82
|
+
patchRow: (id, patch) => { try { deps.hub && deps.hub.patchRow(id, patch); } catch { /* best effort */ } },
|
|
83
|
+
|
|
84
|
+
// The transcript, mirrored for a watching browser. Best effort by design — the web UI
|
|
85
|
+
// lagging is not a reason to fail a turn.
|
|
86
|
+
syncChat: (chat) => { try { deps.hub && deps.hub.syncChat(chat); } catch { /* best effort */ } },
|
|
87
|
+
sync: () => { try { syncWeb && syncWeb(); } catch { /* best effort */ } },
|
|
88
|
+
pubStatusField: (field, value) => {
|
|
89
|
+
try { deps.hub && deps.hub.pubStatusField(field, value); } catch { /* best effort */ }
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// The two things a headless run genuinely cannot do. Returning null rather than throwing
|
|
93
|
+
// lets the caller fall back to a textual answer, which is what the web UI wants anyway.
|
|
94
|
+
edit: (spec) => { openEditor && openEditor(spec); return null; },
|
|
95
|
+
pick: (spec) => { openPicker && openPicker(spec); return null; },
|
|
96
|
+
form: (spec) => { openForm && openForm(spec); return null; },
|
|
97
|
+
|
|
98
|
+
// Terminal geometry, for the parts of a transcript that wrap. A headless run has no width to
|
|
99
|
+
// wrap to, so it reports a conventional one rather than failing.
|
|
100
|
+
dims: () => {
|
|
101
|
+
try {
|
|
102
|
+
const [cols, rows] = stdout.getWindowSize();
|
|
103
|
+
return { cols: cols || 80, rows: rows || 24 };
|
|
104
|
+
} catch { return { cols: 80, rows: 24 }; }
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/** True when this view can show a modal. Callers that need one must check. */
|
|
108
|
+
interactive: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/settings-schema.js
CHANGED
|
@@ -278,15 +278,18 @@ export function displayValue(cfg, key) {
|
|
|
278
278
|
if (!def) return '';
|
|
279
279
|
const v = effectiveValue(cfg, key);
|
|
280
280
|
if (def.kind === 'bool') return v === 'true' ? 'on' : 'off';
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
281
|
+
// `text` is returned WHOLE. It used to be cut to forty characters here, which made the
|
|
282
|
+
// value useless to a caller that needed to measure or edit it. Abbreviating is the
|
|
283
|
+
// renderer's job — see `proseSummary`.
|
|
284
|
+
//
|
|
285
|
+
// An empty value reads as `(unset)` for EVERY kind, not only `string`: `protocol` is an
|
|
286
|
+
// enum whose default is the empty string, so its row rendered with a blank value column.
|
|
287
|
+
//
|
|
288
|
+
// Whitespace alone is unset too. `!v` only catches the empty string, so ' ' rendered as a
|
|
289
|
+
// blank cell — the same as a value that failed to render. Tested on the trimmed copy and the
|
|
290
|
+
// value returned RAW: a trailing newline is part of a prompt, and trimming the value itself
|
|
291
|
+
// would corrupt it.
|
|
292
|
+
if (!v || !String(v).trim()) return '(unset)';
|
|
290
293
|
return v;
|
|
291
294
|
}
|
|
292
295
|
|