@milpa/comal 0.2.5 → 0.2.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 +1 -1
- package/package.json +1 -1
- package/src/agent-status.mjs +106 -0
- package/src/cli.mjs +11 -4
- package/src/i18n/cli-es.mjs +1 -0
- package/src/i18n/es.mjs +2 -0
- package/src/onboarding.mjs +1 -1
- package/src/recent-connections.mjs +82 -0
- package/src/relay/flow.mjs +14 -14
- package/src/tui/controller.mjs +1 -1
- package/src/tui/screen.mjs +21 -5
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ comal --help
|
|
|
28
28
|
|
|
29
29
|
Keep the PATH line in your shell startup file if you use that prefix. If your Node manager already provides a user prefix, you can omit `--prefix "$HOME/.local"`. The package registers the `comal` executable; you do not need to create a shell alias.
|
|
30
30
|
|
|
31
|
-
You can pin a version with `npx @milpa/comal@0.2.
|
|
31
|
+
You can pin a version with `npx @milpa/comal@0.2.7` or `npm install --global @milpa/comal@0.2.7`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
|
|
32
32
|
|
|
33
33
|
## Test without account or model
|
|
34
34
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// What an interactive Comal is doing, said to the outside in two ways:
|
|
5
|
+
// - the terminal title (OSC 0), which any terminal, tab bar or workspace manager can read:
|
|
6
|
+
// "◐ Comal · name" while it works, "? Comal · name" while a question waits for you, "✳ Comal · name" when idle;
|
|
7
|
+
// - herdr's agent socket (pane.report_agent), when Comal runs inside a herdr pane (HERDR_ENV=1).
|
|
8
|
+
// Both are best-effort: a failure is ignored and never touches the session. COMAL_TERMINAL_TITLE=off keeps the title.
|
|
9
|
+
|
|
10
|
+
export const STATES = Object.freeze(['working', 'blocked', 'idle']);
|
|
11
|
+
const GLYPH = { working: '◐', blocked: '?', idle: '✳' };
|
|
12
|
+
const TITLE_MAX = 80;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* blocked while a permission card, or a choice or input pushed to the human (setup.waits: a swarm card, a connection
|
|
16
|
+
* review), waits for an answer; working while a turn runs; idle otherwise. A picker the human opened is not a question.
|
|
17
|
+
*/
|
|
18
|
+
export function agentState({ busy = false, permission = null, setup = null } = {}) {
|
|
19
|
+
if (permission || setup?.waits) return 'blocked';
|
|
20
|
+
return busy ? 'working' : 'idle';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const clean = text => String(text ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '').replace(/\s+/g, ' ').trim();
|
|
24
|
+
|
|
25
|
+
/** The label after "Comal ·": the session name, else the folder name. */
|
|
26
|
+
export function statusLabel({ name, cwd } = {}) {
|
|
27
|
+
return clean(name) || clean(cwd ? path.basename(cwd) : '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function terminalTitle(state, label) {
|
|
31
|
+
const text = `${GLYPH[state] || GLYPH.idle} Comal${label ? ' · ' + clean(label) : ''}`;
|
|
32
|
+
return [...text].slice(0, TITLE_MAX).join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sends one JSON line to herdr's socket; resolves true when herdr answered. Never rejects. */
|
|
36
|
+
function sendLine(socketPath, request, { connect = net.createConnection, timeoutMs = 500 } = {}) {
|
|
37
|
+
return new Promise(resolve => {
|
|
38
|
+
let done = false, socket, timer;
|
|
39
|
+
const finish = ok => { if (done) return; done = true; clearTimeout(timer); try { socket?.destroy(); } catch {} resolve(ok); };
|
|
40
|
+
try { socket = connect(socketPath); } catch { finish(false); return; }
|
|
41
|
+
socket.on('error', () => finish(false));
|
|
42
|
+
socket.on('connect', () => socket.write(JSON.stringify(request) + '\n'));
|
|
43
|
+
socket.on('data', () => finish(true));
|
|
44
|
+
socket.on('end', () => finish(false));
|
|
45
|
+
timer = setTimeout(() => finish(false), timeoutMs); timer.unref?.();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class HerdrReporter {
|
|
50
|
+
constructor({ env = process.env, connect, now = Date.now } = {}) {
|
|
51
|
+
this.socketPath = env.HERDR_SOCKET_PATH; this.paneId = env.HERDR_PANE_ID; this.connect = connect; this.now = now;
|
|
52
|
+
this.enabled = env.HERDR_ENV === '1' && Boolean(this.socketPath) && Boolean(this.paneId);
|
|
53
|
+
this.seq = now() * 1000; this.sessionId = null; this.chain = Promise.resolve();
|
|
54
|
+
}
|
|
55
|
+
// Reports go out one at a time, in order, each with a rising seq, so herdr never keeps an older state.
|
|
56
|
+
send(method, params) {
|
|
57
|
+
if (!this.enabled) return this.chain;
|
|
58
|
+
const request = { id: `comal:${method}:${this.now()}:${Math.random().toString(36).slice(2, 8)}`, method, params: { pane_id: this.paneId, source: 'comal', agent: 'comal', ...params } };
|
|
59
|
+
this.chain = this.chain.then(() => sendLine(this.socketPath, request, { connect: this.connect }));
|
|
60
|
+
return this.chain;
|
|
61
|
+
}
|
|
62
|
+
session(id) {
|
|
63
|
+
if (!id || id === this.sessionId) return this.chain;
|
|
64
|
+
this.sessionId = id;
|
|
65
|
+
return this.send('pane.report_agent_session', { seq: ++this.seq, agent_session_id: id });
|
|
66
|
+
}
|
|
67
|
+
report(state, message) {
|
|
68
|
+
return this.send('pane.report_agent', { state, ...(message ? { message } : {}), seq: ++this.seq, ...(this.sessionId ? { agent_session_id: this.sessionId } : {}) });
|
|
69
|
+
}
|
|
70
|
+
release() { return this.send('pane.release_agent', {}); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class AgentStatus {
|
|
74
|
+
constructor({ output, env = process.env, herdr = new HerdrReporter({ env }), exitTarget = process } = {}) {
|
|
75
|
+
// Only a real terminal says anything: a screen drawn into a pipe or a test double reports nothing anywhere.
|
|
76
|
+
this.output = output; this.herdr = herdr; this.live = Boolean(output?.isTTY);
|
|
77
|
+
this.titles = this.live && !/^(?:off|0|false|no)$/i.test(env.COMAL_TERMINAL_TITLE || '');
|
|
78
|
+
this.title = null; this.state = null; this.message = null; this.closed = false; this.pushed = false;
|
|
79
|
+
// A crash or an exit that never reaches close() still puts the terminal's title back.
|
|
80
|
+
this.exitTarget = exitTarget; this.onExit = () => { if (this.pushed && !this.closed) { try { this.output.write('\x1b[23;0t'); } catch {} } };
|
|
81
|
+
}
|
|
82
|
+
/** The next update writes the title again (after an editor or another program owned the terminal). */
|
|
83
|
+
forgetTitle() { this.title = null; }
|
|
84
|
+
update({ state, label, message, sessionId, titles = true } = {}) {
|
|
85
|
+
if (this.closed || !this.live || !STATES.includes(state)) return;
|
|
86
|
+
if (this.titles && titles) {
|
|
87
|
+
const title = terminalTitle(state, label);
|
|
88
|
+
if (title !== this.title) {
|
|
89
|
+
// Save the terminal's own title once, so closing puts it back (xterm title stack; ignored where unsupported).
|
|
90
|
+
try {
|
|
91
|
+
if (!this.pushed) this.exitTarget?.once?.('exit', this.onExit);
|
|
92
|
+
this.output.write((this.pushed ? '' : '\x1b[22;0t') + `\x1b]0;${title}\x07`); this.pushed = true; this.title = title;
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (sessionId) this.herdr.session(sessionId);
|
|
97
|
+
const text = state === 'blocked' ? clean(message).slice(0, 200) : '';
|
|
98
|
+
if (state !== this.state || text !== this.message) { this.state = state; this.message = text; this.herdr.report(state, text || undefined); }
|
|
99
|
+
}
|
|
100
|
+
close() {
|
|
101
|
+
if (this.closed) return this.herdr.chain; this.closed = true;
|
|
102
|
+
this.exitTarget?.off?.('exit', this.onExit);
|
|
103
|
+
if (this.pushed) { try { this.output.write('\x1b[23;0t'); } catch {} }
|
|
104
|
+
return this.state ? this.herdr.release() : this.herdr.chain;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { remoteCommandHelp, isRemoteCommand } from './remote-commands.mjs';
|
|
|
13
13
|
import { codeRunDefinition } from './code-run.mjs';
|
|
14
14
|
import {installCodeRunSession} from './code-run-session.mjs';
|
|
15
15
|
import { hasProviderSelection, loadConnection, saveConnection, existingLoginConnection, onboard, loadSavedLimits } from './onboarding.mjs';
|
|
16
|
+
import { loadRecentConnection, rememberConnection, recentSelection } from './recent-connections.mjs';
|
|
16
17
|
import { SessionSettings, resolveStartupLimits, modelAwareLimits } from './connection-settings.mjs';
|
|
17
18
|
import { loginProvider, parseLoginArguments, withoutCredentialHeaders } from './secret-login.mjs';
|
|
18
19
|
import { stageImage, descriptorForStage, persistHumanImage } from './image-attachments.mjs';
|
|
@@ -252,7 +253,9 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
252
253
|
const explicitConnection = hasProviderSelection(v, profiles);
|
|
253
254
|
let savedConnection, recoveredLogin, knownLogins = [];
|
|
254
255
|
if (!explicitConnection) {
|
|
255
|
-
|
|
256
|
+
// Interactive: the project's last connection, else the last one used anywhere, else connection.json. A print run
|
|
257
|
+
// (scripts, cron) keeps reading only connection.json, so a model tried interactively never changes it.
|
|
258
|
+
try { savedConnection = (print ? null : await loadRecentConnection({ cwd })) || await loadConnection(); }
|
|
256
259
|
catch (error) { stderr.write(t('Saved connection could not be loaded: ') + error.message + '\n'); }
|
|
257
260
|
if (!savedConnection && !print) {
|
|
258
261
|
const existing = await existingLoginConnection(authManager); knownLogins = existing.logins;
|
|
@@ -358,6 +361,9 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
358
361
|
await store.append(session.id,row);
|
|
359
362
|
Object.assign(session,{model:row.model,provider:row.provider,provider_profile:row.profile,provider_connection:row.connection});
|
|
360
363
|
};
|
|
364
|
+
// Interactive sessions remember the connection chosen without startup flags, and each /model or /provider, for the
|
|
365
|
+
// next start (per project and overall). A print run, a resumed session and a one-off flag or variable never do.
|
|
366
|
+
const rememberActive=async()=>{if(v.demo || print || !provider)return;const selection=recentSelection(settings,provider);if(!selection)return;try{await rememberConnection(selection,{cwd});}catch(error){ui?.notice(t('Connection not remembered for the next start: ')+error.message,true);}};
|
|
361
367
|
const saveProvider=async()=>{if(!v.demo)await persistProvider(provider,settings,modelCapabilities);};
|
|
362
368
|
if(v.name){if(v.name.trim().length<1 || v.name.length>120)throw new Error(t("--name must be 1 to 120 characters."));session.name=clean(v.name.trim());await store.append(session.id,{type:'name',name:session.name});}
|
|
363
369
|
if(v.inbound && !['accept','hold','refuse'].includes(v.inbound))throw new Error(t("--inbound must be accept, hold, or refuse."));
|
|
@@ -864,7 +870,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
864
870
|
{ name: 'SessionMode', description: 'Human changes the permission mode.', input_schema: { type: 'object', properties: { mode: { type: 'string', enum: PERMISSION_MODES } }, required: ['mode'], additionalProperties: false }, run: async ({ mode }) => { policy.mode = mode; await store.append(session.id, { type: 'mode', mode }); return t("Permissions: {v0}", {v0: mode}); } },
|
|
865
871
|
{ name:'SessionModel',description:'Human selects a model without replacing session history.',input_schema:{type:'object',properties:{model:{type:'string',minLength:1,maxLength:256}},required:['model'],additionalProperties:false},run:async({model})=>{
|
|
866
872
|
if(!model.trim() || /\s/.test(model))throw new Error(t("Use /model ID, without spaces."));
|
|
867
|
-
const verification=await replaceProvider({...settings,model,effort:'auto'},{refresh:true,validate:true});
|
|
873
|
+
const verification=await replaceProvider({...settings,model,effort:'auto'},{refresh:true,validate:true});await rememberActive();
|
|
868
874
|
return t("Model: {v0} · effort {v1} · same session", {v0: provider.model, v1: provider.effort || 'auto'}) + (verification?.compatibilityFallback? ' · '+t('Model catalog unavailable; the active connection is unverified. No inference was sent.'):'');
|
|
869
875
|
}},
|
|
870
876
|
{name:'SessionEffort',description:'Human selects a supported reasoning effort for the next request.',input_schema:{type:'object',properties:{effort:{type:'string',minLength:1,maxLength:16}},required:['effort'],additionalProperties:false},run:async({effort})=>{
|
|
@@ -885,7 +891,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
885
891
|
if(v.demo)throw new Error(t("The demo does not allow changing provider."));
|
|
886
892
|
const selected=profiles.profiles.find(item=>item.name===profile);
|
|
887
893
|
const config=selected ? {provider:selected.provider,profile:selected,namedProfile:true,authProvider:selected.authProvider,model:selected.model,baseURL:selected.baseURL,effort:selected.effort || 'auto',maxTokens:contextConfig.maxTokens} : {...authProviderDefaults(profile),effort:'auto',maxTokens:contextConfig.maxTokens};
|
|
888
|
-
const verification=await replaceProvider(config,{validate:true});
|
|
894
|
+
const verification=await replaceProvider(config,{validate:true});await rememberActive();
|
|
889
895
|
return t("Provider: {v0} · {v1} · {v2}", {v0: selected?.name || provider.authProvider, v1: settings.provider, v2: provider.model}) + (verification?.compatibilityFallback?' · '+t('Model catalog unavailable; the active connection is unverified. No inference was sent.'):'');
|
|
890
896
|
}},
|
|
891
897
|
{name:'SessionLogin',description:'Human authorizes a provider login. Only the environment variable name is recorded.',input_schema:{type:'object',properties:{provider:{type:'string',enum:AUTH_PROVIDERS.map(item=>item.id)},apiKeyEnv:{type:'string',pattern:'^[A-Za-z_][A-Za-z0-9_]*$'},hidden:{type:'boolean'}},required:['provider'],additionalProperties:false},effects:{workspace:'none',external:'unknown',process:'none',authority:'credential_control'},run:async({provider:target,apiKeyEnv,hidden})=>loginControl(target+(apiKeyEnv?' --api-key-env '+apiKeyEnv:'')+(hidden?' --key':''))},
|
|
@@ -963,7 +969,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
963
969
|
try{return JSON.stringify(await loginProvider({authManager,ui,provider:target,...login,signal,onEvent:event=>{if(ui){if(event.type==='device_code')ui.notice(t("Login {v0}: {v1} · code {v2}", {v0: event.provider, v1: event.verification_uri, v2: event.user_code}));else if(event.message)ui.notice(event.message);}else authNotice(event);}}),null,2);}
|
|
964
970
|
finally{authController=null;if(ui)ui.setBusy(wasBusy);}
|
|
965
971
|
};
|
|
966
|
-
const sessionSettings=new SessionSettings({limits:contextConfig,flags:{maxTokens:v['max-tokens'],contextWindow:v['context-window']},authManager,delegate:(command,arg)=>backend.control(command,arg),
|
|
972
|
+
const sessionSettings=new SessionSettings({persistConnection:async(selection,options)=>{await saveConnection(selection,options);try{await rememberConnection(selection,{cwd,env:options?.env});}catch{}},limits:contextConfig,flags:{maxTokens:v['max-tokens'],contextWindow:v['context-window']},authManager,delegate:(command,arg)=>backend.control(command,arg),
|
|
967
973
|
applyLimits:async()=>{if(provider)provider.maxTokens=contextConfig.maxTokens;agent.compactor.contextWindow=effectiveContextWindow(modelCapabilities);await saveProvider();},
|
|
968
974
|
state:()=>({demo:Boolean(v.demo),provider:settings.provider,authProvider:provider?.authProvider,profile:provider?.profileName,endpoint:provider?.baseURL || (provider?.url?String(provider.url):undefined),model:provider?.model,effort:provider?.effort,thinking:provider?.thinking,protocol:provider?.protocol,fallback:provider?.fallback,apiKeyEnv:session.provider_connection?.apiKeyEnv,modelCapabilities,mode:policy.mode,plugins:plugins.status(),paths:{profiles:profiles.configPath,plugins:plugins.status().config_path}})});
|
|
969
975
|
const backend = {
|
|
@@ -1165,6 +1171,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
1165
1171
|
if(peerBus.availability?.available===false && !['PEER_DISABLED','PEER_UNAVAILABLE'].includes(peerBus.availability.code)){const note=t('Cross-session messaging is unavailable on this platform. Local work remains available.');if(ui)ui.notice(note,true);else stderr.write(note+'\n');}
|
|
1166
1172
|
await refreshConnections();
|
|
1167
1173
|
await saveProvider();
|
|
1174
|
+
if (!explicitConnection) await rememberActive();
|
|
1168
1175
|
await lifecycle.execute('PluginInitialize', { trust: Boolean(v['trust-plugins'] || skipPermissions) }, { signal: startupSignal });
|
|
1169
1176
|
await lifecycle.execute('McpInitialize', { trust: Boolean(v['trust-mcp'] || skipPermissions) }, { signal: startupSignal });
|
|
1170
1177
|
if(host.codeRunSession) {
|
package/src/i18n/cli-es.mjs
CHANGED
|
@@ -108,5 +108,6 @@ export default Object.freeze({
|
|
|
108
108
|
"Use /permissions default|plan|acceptEdits|auto.": "Usa /permissions default|plan|acceptEdits|auto.",
|
|
109
109
|
"Model: {v0} · effort {v1}\nUse /models, /model ID, or /effort LEVEL.": "Modelo: {v0} · effort {v1}\nUsa /models, /model ID o /effort NIVEL.",
|
|
110
110
|
"There are no saved sessions.\n": "No hay sesiones guardadas.\n",
|
|
111
|
+
"Connection not remembered for the next start: ": "La conexión no se recordó para el próximo inicio: ",
|
|
111
112
|
"Comal · standalone CLI programming assistant\n\nUsage: node bin/comal.mjs [options] [prompt]\n node bin/comal.mjs sessions | evidence | audit --resume UUID\n\n -p, --print Runs a turn and exits (also accepts stdin)\n --demo Deterministic demo without credentials; allows its edits\n --cwd <path> Workspace (default: current directory)\n --provider <protocol> openai (default) | anthropic | gemini\n --profile <name> Profile from .comal/providers.json (endpoint/model/capabilities)\n --providers-config <file> Profiles file; keys only via apiKeyEnv\n --name <name> Name visible to other sessions\n --listen Keeps the CLI listening for messages and responds when idle\n --inbound <mode> accept (default) | hold | refuse\n --no-peers No cross-session bus (also COMAL_PEERS=off)\n --auth-provider <id> Comal login: codex | kimi | minimax | grok\n --effort <level> auto | off | minimal | low | medium | high | xhigh | max (if the model supports it)\n --model <id> Model; by default discovers the local one at /v1/models\n --base-url <url> Endpoint; default http://llama.local:11438/\n --permission-mode <mode> default | acceptEdits | plan | auto\n --allowed-tools <names> Authorizes comma-separated names\n --disallowed-tools <names> Denies names; overrides authorization\n --resume <uuid> Resumes session; with -p and no text continues pending work\n -c, --continue Resumes the most recent session\n --output-format <format> text | json | stream-json (with --print)\n --max-turns <n> Calls before extending work with progress (12)\n --work-max-calls <n> Total limit with task continuation (80; 0 disables)\n --context-window <n> Explicit limit; automatic based on model, fallback 45000\n --max-tokens <n> Initial maximum output tokens (8192; up to 32000 when the model allows)\n --autocompact <n|auto|off> Compaction window: N or Nk tokens, auto or off (also COMAL_AUTOCOMPACT)\n --thinking <mode> auto | on | off | low | medium | high (llama.cpp)\n --vision <mode> auto | on | off (auto queries /props when receiving images)\n --image-tokens <n> Estimated reservation per image (4096, maximum 2 images)\n --model-timeout-ms <n> Total time for each OpenAI response (600000)\n --model-idle-timeout-ms <n> Time without model token progress (180000)\n --system-prompt <text> Additional instructions\n --state-dir <path> State location; default .comal/ (reuses existing .harness/)\n --mcp-config <file> Connects MCP stdio, Streamable HTTP, or SSE from a JSON\n --trust-mcp Authorizes initial connections; changes are reviewed again\n --plugin-config <file> Local Node plugins (JSON); default .comal/plugins.json\n --lang <en|es|auto> UI language (default: en; auto opts into locale detection)\n --codemode Read-only JavaScript tool composition (default: on)\n --no-codemode Disable CodeRun for this process\n --trust-plugins Authorizes loading local code from the initial config\n --chrome-task <file.json> Explicit Chrome task; the house coordinates publication, checks, and closure\n --goal <condition> Runs a goal until verification or limit\n --goal-max-turns <n> Maximum goal turns (20)\n --goal-max-calls <n> Total calls: agent + verifiers (80)\n --goal-max-tokens <n> Goal token budget (100000)\n -h, --help Shows this help\n -v, --version Shows version\n\nVariables (HARNESS_* remains as alias): COMAL_PROVIDER, COMAL_BASE_URL, COMAL_MODEL,\n COMAL_CONTEXT_WINDOW, COMAL_MAX_TOKENS, COMAL_WORK_MAX_CALLS,\n COMAL_MODEL_TIMEOUT_MS, COMAL_MODEL_IDLE_TIMEOUT_MS,\n OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_KEY (optional for local),\n ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL (with --provider anthropic).\nRead authorized by default; write and Bash require permission.\nIn --print without a terminal, unauthorized tools are denied.\nShift+Tab: default → acceptEdits → plan → auto → default.\nInteractive commands: /help /status /tools /house /operations /sessions /resume UUID /clear\n /compact /context /state /audit /board /work resume|recover\n /model ID /permissions MODE /goal [condition] /exit\n /queue [pause|resume|clear|edit N] /rename NAME /table FILE\nTUI: F1 help · F2 sessions · F4 queue · F5 or /select selection · F6 discover commands/tools.\n / and Tab complete; ↑/↓ chooses; Enter on suggestions only completes. Alt+C or /copy copies.\n Ctrl+G editor · Alt+Enter multiline.\nMCP: /mcp · /mcp reload [file] · /mcp add NAME {JSON} · /mcp remove NAME\n /mcp reconnect NAME. Default file: .mcp.json in the workspace.\nWeb: /search QUERY · WebSearch/WebFetch · COMAL_SEARCH_PROVIDER, SEARXNG_URL, BRAVE_SEARCH_API_KEY.\nSubagents: /agent [explore|general] TASK · /agents [UUID] · Agent/AgentBatch.\nPlugins: /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERATION {JSON}].\nLive sessions: /list-agents · /send ID_OR_NAME TEXT · /inbox [accept|hold|refuse|release ID|all]\nComal account: comal cloud login|status|logout [--cloud-url ORIGIN] (separate from the model provider)\nArtifacts: comal artifacts list|inspect ID|verify ID|publish DIR --manifest JSON|release ID VERSION JOB\nLogin: comal login PROVIDER [--api-key-env VARIABLE] · comal auth status [PROVIDER] · comal logout PROVIDER\nModels: /model [ID] · /models · /effort [LEVEL] · /capabilities [refresh] · /login PROVIDER · /logout PROVIDER\nProviders: /providers · /provider PROFILE · ProviderSearch/AnalyzeMedia according to capabilities.\nCLI: comal peers | providers | message DESTINATION TEXT (use --listen to receive in CLI).\n/goal status|pause|resume|clear controls the goal; Ctrl+C pauses it.\nLocal skills: .comal/skills/<name>/SKILL.md → /<name> [task]\n\nRemote Control: /rc [start|status|stop] · TUI account: /cloud [login|status|logout] · CLI: comal cloud login\n": "Comal · asistente de programación CLI independiente\n\nUso: node bin/comal.mjs [opciones] [prompt]\n node bin/comal.mjs sessions | evidence | audit --resume UUID\n\n -p, --print Ejecuta un turno y termina (también acepta stdin)\n --demo Demo determinista sin credenciales; permite sus ediciones\n --cwd <ruta> Workspace (por defecto: directorio actual)\n --provider <protocolo> openai (predeterminado) | anthropic | gemini\n --profile <nombre> Perfil de .comal/providers.json (endpoint/modelo/capacidades)\n --providers-config <archivo> Archivo de perfiles; claves sólo vía apiKeyEnv\n --name <nombre> Nombre visible a otras sesiones\n --listen Mantiene el CLI escuchando mensajes y responde al quedar idle\n --inbound <modo> accept (predeterminado) | hold | refuse\n --no-peers Sin bus entre sesiones (también COMAL_PEERS=off)\n --auth-provider <id> Login de Comal: codex | kimi | minimax | grok\n --effort <nivel> auto | off | minimal | low | medium | high | xhigh | max (si el modelo lo admite)\n --model <id> Modelo; por defecto descubre el local en /v1/models\n --base-url <url> Endpoint; por defecto http://llama.local:11438/\n --permission-mode <modo> default | acceptEdits | plan | auto\n --allowed-tools <nombres> Autoriza nombres separados por comas\n --disallowed-tools <nombres> Deniega nombres; prevalece sobre autorización\n --resume <uuid> Reanuda sesión; con -p sin texto continúa trabajo pendiente\n -c, --continue Reanuda la sesión más reciente\n --output-format <formato> text | json | stream-json (con --print)\n --max-turns <n> Llamadas antes de extender trabajo con progreso (12)\n --work-max-calls <n> Límite total con continuación de tareas (80; 0 desactiva)\n --context-window <n> Límite explícito; automático según modelo, fallback 45000\n --max-tokens <n> Máximo inicial de tokens de salida (8192; hasta 32000 si el modelo lo admite)\n --autocompact <n|auto|off> Ventana de compactación: N o Nk tokens, auto u off (también COMAL_AUTOCOMPACT)\n --thinking <modo> auto | on | off | low | medium | high (llama.cpp)\n --vision <modo> auto | on | off (auto consulta /props al recibir imágenes)\n --image-tokens <n> Reserva estimada por imagen (4096, máximo 2 imágenes)\n --model-timeout-ms <n> Tiempo total de cada respuesta OpenAI (600000)\n --model-idle-timeout-ms <n> Tiempo sin progreso de tokens del modelo (180000)\n --system-prompt <texto> Instrucciones adicionales\n --state-dir <ruta> Ubicación del estado; por defecto .comal/ (reutiliza .harness/ existente)\n --mcp-config <archivo> Conecta MCP stdio, Streamable HTTP o SSE de un JSON\n --trust-mcp Autoriza conexiones iniciales; cambios se revisan de nuevo\n --plugin-config <archivo> Plugins Node locales (JSON); por defecto .comal/plugins.json\n --trust-plugins Autoriza cargar código local de la config inicial\n --codemode Experimental read-only JavaScript tool composition\n --chrome-task <archivo.json> Tarea Chrome explícita; la casa coordina publicación, checks y cierre\n --goal <condición> Ejecuta un objetivo hasta verificación o límite\n --goal-max-turns <n> Máximo de turnos del objetivo (20)\n --goal-max-calls <n> Llamadas totales: agente + verificadores (80)\n --goal-max-tokens <n> Presupuesto de tokens del objetivo (100000)\n -h, --help Muestra esta ayuda\n -v, --version Muestra versión\n\nVariables (HARNESS_* sigue como alias): COMAL_PROVIDER, COMAL_BASE_URL, COMAL_MODEL,\n COMAL_CONTEXT_WINDOW, COMAL_MAX_TOKENS, COMAL_WORK_MAX_CALLS,\n COMAL_MODEL_TIMEOUT_MS, COMAL_MODEL_IDLE_TIMEOUT_MS,\n OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_KEY (opcional para local),\n ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL (con --provider anthropic).\nLectura autorizada por defecto; escritura y Bash requieren permiso.\nEn --print sin terminal, las herramientas no autorizadas se deniegan.\nShift+Tab: default → acceptEdits → plan → auto → default.\nRemote Control: /rc [start|status|stop] · TUI account: /cloud [login|status|logout] · CLI: comal cloud login\nComandos interactivos: /help /status /tools /house /operations /sessions /resume UUID /clear\n /compact /context /state /audit /board /work resume|recover\n /model ID /permissions MODO /goal [condición] /exit\n /queue [pause|resume|clear|edit N] /rename NOMBRE /table ARCHIVO\nTUI: F1 ayuda · F2 sesiones · F4 cola · F5 o /select selección · F6 descubrir comandos/herramientas.\n / y Tab completan; ↑/↓ elige; Enter en sugerencias sólo completa. Alt+C o /copy copiar.\n Ctrl+G editor · Alt+Enter multilínea.\nMCP: /mcp · /mcp reload [archivo] · /mcp add NOMBRE {JSON} · /mcp remove NOMBRE\n /mcp reconnect NOMBRE. Archivo predeterminado: .mcp.json del workspace.\nWeb: /search CONSULTA · WebSearch/WebFetch · COMAL_SEARCH_PROVIDER, SEARXNG_URL, BRAVE_SEARCH_API_KEY.\nSubagentes: /agent [explore|general] TAREA · /agents [UUID] · Agent/AgentBatch.\nPlugins: /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERACIÓN {JSON}].\nSesiones vivas: /list-agents · /send ID_O_NOMBRE TEXTO · /inbox [accept|hold|refuse|release ID|all]\nCuenta Comal: comal cloud login|status|logout [--cloud-url ORIGEN] (separada del proveedor de modelos)\nArtifacts: comal artifacts list|inspect ID|verify ID|publish DIR --manifest JSON|release ID VERSION JOB\nLogin: comal login PROVEEDOR [--api-key-env VARIABLE] · comal auth status [PROVEEDOR] · comal logout PROVEEDOR\nModelos: /model [ID] · /models · /effort [NIVEL] · /capabilities [refresh] · /login PROVEEDOR · /logout PROVEEDOR\nProveedores: /providers · /provider PERFIL · ProviderSearch/AnalyzeMedia según capacidades.\nCLI: comal peers | providers | message DESTINO TEXTO (usa --listen para recibir en CLI).\n/goal status|pause|resume|clear controla el objetivo; Ctrl+C lo pausa.\nSkills locales: .comal/skills/<nombre>/SKILL.md → /<nombre> [tarea]\n\n --lang <en|es|auto> Idioma de interfaz (predeterminado: en)\n\n --no-codemode Desactiva CodeRun para este proceso\n"
|
|
112
113
|
});
|
package/src/i18n/es.mjs
CHANGED
|
@@ -160,6 +160,8 @@ export default Object.freeze({
|
|
|
160
160
|
"vision": "visión",
|
|
161
161
|
"connecting": "conectando",
|
|
162
162
|
"Enlarge the terminal to 24×10": "Amplía el terminal a 24×10",
|
|
163
|
+
"Permission: {v0}": "Permiso: {v0}",
|
|
164
|
+
"Waiting for your answer": "Esperando tu respuesta",
|
|
163
165
|
" {v0} interrupted message(s); not resent. R requeues the first.": " {v0} mensaje(s) interrumpidos; no se reenviaron. R reencola el primero.",
|
|
164
166
|
" Empty queue. Send a message while the agent works.": " Cola vacía. Envía un mensaje mientras el agente trabaja.",
|
|
165
167
|
" Running: ": " En ejecución: ",
|
package/src/onboarding.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function normalizeFile(value) {
|
|
|
49
49
|
if (!Object.keys(selected).length && !Object.keys(limits).length) throw Error('Invalid saved Comal connection.');
|
|
50
50
|
return { selection: Object.keys(selected).length ? normalizeSelection(selected) : null, limits };
|
|
51
51
|
}
|
|
52
|
-
function normalizeConnection(value) {
|
|
52
|
+
export function normalizeConnection(value) {
|
|
53
53
|
if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !SELECTION_KEYS.includes(key))) throw Error('Invalid saved Comal connection.');
|
|
54
54
|
return normalizeSelection(value);
|
|
55
55
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, rename, unlink, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { normalizeConnection } from './onboarding.mjs';
|
|
6
|
+
import { AUTH_PROVIDERS } from './provider-auth.mjs';
|
|
7
|
+
|
|
8
|
+
// The last connection used, per project and overall. Comal writes it on its own
|
|
9
|
+
// whenever an interactive session connects or changes provider or model; a new
|
|
10
|
+
// session opens with the project's last one, else the last one used anywhere.
|
|
11
|
+
// Startup flags, environment variables and named profiles still win. Holds the
|
|
12
|
+
// same routing data as connection.json (login name or endpoint, and model),
|
|
13
|
+
// never a key.
|
|
14
|
+
export const RECENT_PROJECTS_MAX = 100;
|
|
15
|
+
const MAX_BYTES = 65536, VERSION = 1;
|
|
16
|
+
export const recentConnectionsFile = (env = process.env) => path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'comal', 'recent-connections.json');
|
|
17
|
+
|
|
18
|
+
const valid = selection => { try { return normalizeConnection(selection); } catch { return null; } };
|
|
19
|
+
const projectKey = async cwd => { try { return await realpath(cwd); } catch { return path.resolve(cwd); } };
|
|
20
|
+
|
|
21
|
+
async function read(env) {
|
|
22
|
+
try {
|
|
23
|
+
const text = await readFile(recentConnectionsFile(env), 'utf8');
|
|
24
|
+
if (Buffer.byteLength(text) > MAX_BYTES) return null;
|
|
25
|
+
const data = JSON.parse(text);
|
|
26
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || data.version !== VERSION) return null;
|
|
27
|
+
const projects = {};
|
|
28
|
+
if (data.projects && typeof data.projects === 'object' && !Array.isArray(data.projects))
|
|
29
|
+
for (const [dir, entry] of Object.entries(data.projects)) {
|
|
30
|
+
const selection = valid(entry?.selection);
|
|
31
|
+
if (selection && path.isAbsolute(dir) && Number.isSafeInteger(entry.at)) projects[dir] = { selection, at: entry.at };
|
|
32
|
+
}
|
|
33
|
+
return { global: valid(data.global), projects };
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The last connection of this folder or the nearest folder above it that has one (a subfolder of a project counts as
|
|
39
|
+
* the project), else the last one used anywhere, else null. A damaged file reads as nothing.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadRecentConnection({ cwd = process.cwd(), env = process.env } = {}) {
|
|
42
|
+
const data = await read(env);
|
|
43
|
+
if (!data) return null;
|
|
44
|
+
for (let dir = await projectKey(cwd); ; dir = path.dirname(dir)) {
|
|
45
|
+
if (data.projects[dir]) return data.projects[dir].selection;
|
|
46
|
+
if (path.dirname(dir) === dir) return data.global || null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remembers a connection for this project and as the last one used. Throws on a selection connection.json would refuse. */
|
|
51
|
+
export async function rememberConnection(selection, { cwd = process.cwd(), env = process.env, now = Date.now() } = {}) {
|
|
52
|
+
const clean = normalizeConnection(selection);
|
|
53
|
+
const data = (await read(env)) || { global: null, projects: {} };
|
|
54
|
+
const key = await projectKey(cwd);
|
|
55
|
+
delete data.projects[key];
|
|
56
|
+
data.projects[key] = { selection: clean, at: now };
|
|
57
|
+
// Newest first; within the same instant, the one written last counts as newer. The file keeps them oldest first.
|
|
58
|
+
const kept = Object.entries(data.projects).reverse().sort((a, b) => b[1].at - a[1].at).slice(0, RECENT_PROJECTS_MAX);
|
|
59
|
+
// Past the size cap the oldest folders go first; this folder's own entry and the global one always stay.
|
|
60
|
+
const encode = () => JSON.stringify({ version: VERSION, global: clean, projects: Object.fromEntries([...kept].reverse()) }) + '\n';
|
|
61
|
+
let text = encode();
|
|
62
|
+
while (Buffer.byteLength(text) > MAX_BYTES && kept.length > 1) { kept.pop(); text = encode(); }
|
|
63
|
+
if (Buffer.byteLength(text) > MAX_BYTES) throw Error('Recent connections exceed 64 KiB.');
|
|
64
|
+
const file = recentConnectionsFile(env), temporary = file + '.' + randomUUID();
|
|
65
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
66
|
+
try { await writeFile(temporary, text, { flag: 'wx', mode: 0o600 }); await rename(temporary, file); }
|
|
67
|
+
finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The routing data worth remembering for an active connection: a subscription login with its model, or an
|
|
72
|
+
* OpenAI-compatible endpoint with its model. A named profile is chosen by defaultProfile, and a provider that only
|
|
73
|
+
* reads a key from the environment is chosen by that environment: neither is remembered (null).
|
|
74
|
+
*/
|
|
75
|
+
export function recentSelection(config = {}, provider = {}) {
|
|
76
|
+
const model = typeof provider.model === 'string' && provider.model.trim() ? { model: provider.model } : {};
|
|
77
|
+
if (config.namedProfile) return null;
|
|
78
|
+
const login = config.authProvider || provider.authProvider;
|
|
79
|
+
if (login) return AUTH_PROVIDERS.some(item => item.id === login) ? valid({ 'auth-provider': login, ...model }) : null;
|
|
80
|
+
if (config.provider === 'openai' && provider.baseURL) return valid({ provider: 'openai', 'base-url': String(provider.baseURL), ...model });
|
|
81
|
+
return null;
|
|
82
|
+
}
|
package/src/relay/flow.mjs
CHANGED
|
@@ -558,7 +558,7 @@ export class RelayFlow {
|
|
|
558
558
|
if (got?.proposal?.status && got.proposal.status !== 'pending') { this.ui.notice(t('Plan {id} is already {status}.', { id: proposal_id, status: got.proposal.status })); return 'decided'; }
|
|
559
559
|
// Cómo se comprueba cada tarea: la descripción de los checks del proyecto (project.json del hub).
|
|
560
560
|
const checks = (await this.#projectFile({ root }, project))?.checks || [];
|
|
561
|
-
const choice = await this.ui.setupChoice({ ...planCard({ got, checks }), signal: card.signal });
|
|
561
|
+
const choice = await this.ui.setupChoice({ waits: true, ...planCard({ got, checks }), signal: card.signal });
|
|
562
562
|
if (this.#elsewhere(key)) return 'decided';
|
|
563
563
|
const digest = got.proposal.digest, apply = extra => () => this.applyPlan({ project, root, swarm, proposal_id, digest, ...extra });
|
|
564
564
|
const done = result => {
|
|
@@ -569,7 +569,7 @@ export class RelayFlow {
|
|
|
569
569
|
};
|
|
570
570
|
if (choice === 'approve') return await this.#confirm(key, apply({ choice: 'approve' }), done);
|
|
571
571
|
if (choice === 'reject') {
|
|
572
|
-
const reason = await this.ui.setupInput({ title: t('Reason for the lead'), description: t('The lead is woken up with exactly this text and proposes again.'), signal: card.signal });
|
|
572
|
+
const reason = await this.ui.setupInput({ waits: true, title: t('Reason for the lead'), description: t('The lead is woken up with exactly this text and proposes again.'), signal: card.signal });
|
|
573
573
|
if (this.#elsewhere(key)) return 'decided';
|
|
574
574
|
if (!reason?.trim()) return 'later';
|
|
575
575
|
return await this.#confirm(key, apply({ choice: 'reject', reason: reason.trim() }), done);
|
|
@@ -618,7 +618,7 @@ export class RelayFlow {
|
|
|
618
618
|
if (this.#elsewhere(key)) return 'decided';
|
|
619
619
|
const card = this.broker.open(key, this.signal);
|
|
620
620
|
try {
|
|
621
|
-
const choice = await this.ui.setupChoice({ ...criteriaCard({ got, swarm }), signal: card.signal });
|
|
621
|
+
const choice = await this.ui.setupChoice({ waits: true, ...criteriaCard({ got, swarm }), signal: card.signal });
|
|
622
622
|
if (this.#elsewhere(key)) return 'decided';
|
|
623
623
|
const done = result => {
|
|
624
624
|
if (result.outcome === 'approved') this.ui.notice(t('Success criteria v{version} accepted ({status}): the lead can plan.', { version: got.current.version, status: result.status }));
|
|
@@ -630,14 +630,14 @@ export class RelayFlow {
|
|
|
630
630
|
// open-relay v2.33: con avisos, aceptar se confirma aparte (por omisión, volver).
|
|
631
631
|
const warnings = criteriaWarnings(got.current.items, { checked: got.basis_checked === true });
|
|
632
632
|
if (warnings.size) {
|
|
633
|
-
const sure = await this.ui.setupChoice({ ...criteriaConfirmCard({ got, swarm, warnings }), signal: card.signal });
|
|
633
|
+
const sure = await this.ui.setupChoice({ waits: true, ...criteriaConfirmCard({ got, swarm, warnings }), signal: card.signal });
|
|
634
634
|
if (this.#elsewhere(key)) return 'decided';
|
|
635
635
|
if (sure !== 'accept') return 'later';
|
|
636
636
|
}
|
|
637
637
|
return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'accept' }), done);
|
|
638
638
|
}
|
|
639
639
|
if (choice === 'reject') {
|
|
640
|
-
const reason = await this.ui.setupInput({ title: t('Reason for the reviewer'), description: t('The reviewer is woken up with exactly this text and proposes again.'), signal: card.signal });
|
|
640
|
+
const reason = await this.ui.setupInput({ waits: true, title: t('Reason for the reviewer'), description: t('The reviewer is woken up with exactly this text and proposes again.'), signal: card.signal });
|
|
641
641
|
if (this.#elsewhere(key)) return 'decided';
|
|
642
642
|
if (!reason?.trim()) return 'later';
|
|
643
643
|
return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'reject', reason: reason.trim() }), done);
|
|
@@ -653,7 +653,7 @@ export class RelayFlow {
|
|
|
653
653
|
const { call, mutate } = this.#calls(project, root);
|
|
654
654
|
const got = await call('criteria.get', { swarm_id: swarm }, this.signal);
|
|
655
655
|
if (got?.status !== 'accepted' || got?.current?.digest !== digest) { this.ui.notice(t('Criteria of {swarm} are already {status}.', { swarm, status: got?.status || '?' })); return 'decided'; }
|
|
656
|
-
const reason = await this.ui.setupInput({ title: t('Why reopen the criteria'), description: t('The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.', { version: got.current.version }), signal: this.signal });
|
|
656
|
+
const reason = await this.ui.setupInput({ waits: true, title: t('Why reopen the criteria'), description: t('The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.', { version: got.current.version }), signal: this.signal });
|
|
657
657
|
if (!reason?.trim()) return 'later';
|
|
658
658
|
const result = await reopenCriteria(mutate, { swarm_id: swarm, digest, reason: reason.trim() }, this.signal);
|
|
659
659
|
await this.record({ action: 'criteria.reopen', project, swarm, digest, version: got.current.version, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
|
|
@@ -683,9 +683,9 @@ export class RelayFlow {
|
|
|
683
683
|
const { call, mutate } = this.#calls(project, root);
|
|
684
684
|
const got = await call('task.get', { task_id: brief?.id }, this.signal), task = got?.task || got;
|
|
685
685
|
if (task?.state !== 'needs_attention' || !task?.review?.pending) { this.ui.notice(t('The review of {task} no longer waits for you.', { task: line(brief?.id || '?', 60) })); return 'decided'; }
|
|
686
|
-
const choice = await this.ui.setupChoice({ ...escalationCard({ task }), signal: this.signal });
|
|
686
|
+
const choice = await this.ui.setupChoice({ waits: true, ...escalationCard({ task }), signal: this.signal });
|
|
687
687
|
if (choice === 'retry') {
|
|
688
|
-
const why = await this.ui.setupInput({ title: t('Why another review'), description: t('Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.'), signal: this.signal });
|
|
688
|
+
const why = await this.ui.setupInput({ waits: true, title: t('Why another review'), description: t('Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.'), signal: this.signal });
|
|
689
689
|
if (!why?.trim()) return 'later';
|
|
690
690
|
const retried = await retryReview(mutate, { task_id: task.id, reason: why.trim() }, this.signal);
|
|
691
691
|
await this.record({ action: 'review.retry', project, swarm, task: task.id, reason: why.trim(), status: retried.status, idempotency_key: retried.idempotency_key });
|
|
@@ -693,7 +693,7 @@ export class RelayFlow {
|
|
|
693
693
|
return 'decided';
|
|
694
694
|
}
|
|
695
695
|
if (choice !== 'override') return 'later';
|
|
696
|
-
const reason = await this.ui.setupInput({ title: t('Why you accept over the reviewer'), description: t('Recorded in relayd as your decision (review.override) with exactly this text.'), signal: this.signal });
|
|
696
|
+
const reason = await this.ui.setupInput({ waits: true, title: t('Why you accept over the reviewer'), description: t('Recorded in relayd as your decision (review.override) with exactly this text.'), signal: this.signal });
|
|
697
697
|
if (!reason?.trim()) return 'later';
|
|
698
698
|
const result = await overrideReview(mutate, { task_id: task.id, reason: reason.trim() }, this.signal);
|
|
699
699
|
await this.record({ action: 'review.override', project, swarm, task: task.id, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
|
|
@@ -723,7 +723,7 @@ export class RelayFlow {
|
|
|
723
723
|
try {
|
|
724
724
|
const { call } = this.#calls(project, root);
|
|
725
725
|
const message = await call('msg.get', { message_id }, card.signal), block = decisionBlock(message);
|
|
726
|
-
const choice = await this.ui.setupChoice({ ...questionCard({ message, block, task }), signal: card.signal });
|
|
726
|
+
const choice = await this.ui.setupChoice({ waits: true, ...questionCard({ message, block, task }), signal: card.signal });
|
|
727
727
|
if (this.#elsewhere(key)) return 'decided';
|
|
728
728
|
const done = result => {
|
|
729
729
|
if (result.outcome === 'decided') this.ui.notice(t('Decision recorded as {adr} and sent to {to}.', { adr: result.adr || '?', to: result.to }));
|
|
@@ -733,14 +733,14 @@ export class RelayFlow {
|
|
|
733
733
|
};
|
|
734
734
|
if (choice?.startsWith('option:') && block) {
|
|
735
735
|
const index = Number(choice.slice(7)), option = block.options[index]?.label; if (!option) return 'later';
|
|
736
|
-
const note = await this.ui.setupInput({ title: t('Note for the ADR (optional)'), description: t('Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.', { option }), signal: card.signal });
|
|
736
|
+
const note = await this.ui.setupInput({ waits: true, title: t('Note for the ADR (optional)'), description: t('Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.', { option }), signal: card.signal });
|
|
737
737
|
if (this.#elsewhere(key)) return 'decided';
|
|
738
738
|
if (note === null || note === undefined) return 'later';
|
|
739
739
|
return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'option', option_index: index, text: note }), done);
|
|
740
740
|
}
|
|
741
741
|
if (choice === 'reply') {
|
|
742
742
|
const to = message?.from?.role ? `role ${line(message.from.role, 60)}` : line(message?.from?.kind || '?', 40);
|
|
743
|
-
const body = await this.ui.setupInput({ title: t('Your answer'), description: t('Exact text sent to {to} (msg.reply).', { to }), signal: card.signal });
|
|
743
|
+
const body = await this.ui.setupInput({ waits: true, title: t('Your answer'), description: t('Exact text sent to {to} (msg.reply).', { to }), signal: card.signal });
|
|
744
744
|
if (this.#elsewhere(key)) return 'decided';
|
|
745
745
|
if (!body?.trim()) return 'later';
|
|
746
746
|
return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'reply', text: body }), done);
|
|
@@ -856,7 +856,7 @@ export class RelayFlow {
|
|
|
856
856
|
const id = line(task?.id || '?', 60), controlDir = path.join(hubRoot || '.', 'projects', project, 'ctl');
|
|
857
857
|
const command = `relay --control-dir ${shellWord(controlDir)} task resume ${shellWord(String(task?.id || '?'))}`;
|
|
858
858
|
const why = String(detail?.state || '').startsWith('blocked') || detail?.state === 'needs_attention' ? blockLabel(detail.state) : null;
|
|
859
|
-
const choice = await this.ui.setupChoice({ ...blockedCard({ task: detail, reason, check, at: at ?? task?.since, why, retry: { command } }), signal: this.signal });
|
|
859
|
+
const choice = await this.ui.setupChoice({ waits: true, ...blockedCard({ task: detail, reason, check, at: at ?? task?.since, why, retry: { command } }), signal: this.signal });
|
|
860
860
|
if (choice !== 'retry') return 'later';
|
|
861
861
|
this.ui.notice(t('Retry it yourself in your terminal, exactly: {command}', { command }));
|
|
862
862
|
await this.record({ action: 'blocked.shown', project, swarm, task: task?.id || null, check: check || null, reason: line(reason || '', 200) });
|
|
@@ -876,7 +876,7 @@ export class RelayFlow {
|
|
|
876
876
|
const review = await this.#finalReview({ root: root || this.current?.hub_root, project, tasks });
|
|
877
877
|
await this.state.setSwarm(project, swarm, { finished_at: new Date().toISOString() });
|
|
878
878
|
await this.record({ action: 'swarm.finished', project, swarm, branch, base_commit: base || null, counts, ok, evidence: evidence.length });
|
|
879
|
-
const choice = await this.ui.setupChoice({ ...finishCard({ swarm, objective: info.objective || view?.objective, branch, counts, diffStat: stat, base: base || '?', ok, evidence, review }), signal: this.signal });
|
|
879
|
+
const choice = await this.ui.setupChoice({ waits: true, ...finishCard({ swarm, objective: info.objective || view?.objective, branch, counts, diffStat: stat, base: base || '?', ok, evidence, review }), signal: this.signal });
|
|
880
880
|
if (choice === 'diff' && base) {
|
|
881
881
|
const diff = await this.git.diff(repo, base, branch);
|
|
882
882
|
this.ui.commandResult?.('relay', 'diff', (diff.truncated ? t('Diff of {branch} ({count} more lines not shown; git diff {base}..{branch})', { branch, count: diff.truncated, base: base.slice(0, 8) }) + '\n\n' : '') + diff.text);
|
package/src/tui/controller.mjs
CHANGED
|
@@ -280,7 +280,7 @@ export class InteractiveController {
|
|
|
280
280
|
if(state.reason==='authentication' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
|
|
281
281
|
items.push({id:'choose',label:t('Choose another connection'),description:t('Open the provider and workspace profile selector.')});
|
|
282
282
|
const reason=CONNECTION_REASON_HINTS[state.reason];
|
|
283
|
-
const choice=await this.ui.setupChoice({title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
|
|
283
|
+
const choice=await this.ui.setupChoice({waits:true,title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
|
|
284
284
|
if(this.closed)return;
|
|
285
285
|
if(choice==='retry' && this.connections.canRetry(id))await this.submit(`/${action.command} ${action.argument}`);
|
|
286
286
|
else if(choice==='login')await this.submit(`/login ${loginProvider}`);
|
package/src/tui/screen.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { noticeMailbox } from '../plugin-notices.mjs';
|
|
|
14
14
|
import { presentResultBlock, presentToolBlock, presentPermission } from './present.mjs';
|
|
15
15
|
import { normalizeSwarmView, swarmPanelLines, swarmStripLines, swarmActions } from './swarm-panel.mjs';
|
|
16
16
|
import { formatTurnTiming, measureHumanWait } from '../turn-timing.mjs';
|
|
17
|
+
import { AgentStatus, agentState, statusLabel } from '../agent-status.mjs';
|
|
17
18
|
|
|
18
19
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
19
20
|
const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
|
|
@@ -33,6 +34,7 @@ export class TerminalScreen {
|
|
|
33
34
|
start() {
|
|
34
35
|
if (this.started || this.closed) return this;
|
|
35
36
|
this.started = true;
|
|
37
|
+
this.agentStatus ||= new AgentStatus({ output: this.output, env: this.env });
|
|
36
38
|
this.originalRaw = this.input.isRaw || false;
|
|
37
39
|
this.input.setRawMode?.(true); this.input.resume();
|
|
38
40
|
this.decoder = new TerminalInput({ onKey: (text, key) => { Promise.resolve(this.handleKey(text, key)).catch(error => this.notice(error.message, true)); }, onMouse: event => this.handleMouse(event), onPaste: text => this.paste(text), onError: error => this.notice(error.message, true) });
|
|
@@ -67,20 +69,22 @@ export class TerminalScreen {
|
|
|
67
69
|
clearSetup() { this.setup?.finish(null); }
|
|
68
70
|
// keys: atajos de una letra → id de opción (p. ej. {e:'edit', r:'reject'}), sólo sin filtro.
|
|
69
71
|
// hint: línea de ayuda propia. Una descripción larga (tarjetas de /relay) se desplaza con PgUp/PgDn.
|
|
70
|
-
|
|
72
|
+
// waits: the choice was pushed to the human (a swarm card, a connection review) and Comal waits on the answer; it is
|
|
73
|
+
// reported as a question waiting. A picker the human opened (/model, /provider) is not.
|
|
74
|
+
setupChoice({ title, description = '', items, signal, searchable = false, selected, keys, hint, waits = false } = {}) {
|
|
71
75
|
if (!Array.isArray(items) || !items.length || items.length > 1024 || items.some(item => typeof item?.id !== 'string' || !item.id || typeof item.label !== 'string') || new Set(items.map(item => item.id)).size !== items.length) throw new Error(t('Choose between 1 and 1024 distinct options.'));
|
|
72
76
|
const shortcuts = !searchable && keys && typeof keys === 'object' ? Object.fromEntries(Object.entries(keys).filter(([key, id]) => /^[a-z]$/.test(key) && items.some(item => item.id === id))) : null;
|
|
73
|
-
return new Promise(resolve => this.beginSetup({ kind: 'choice', title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '', keys: shortcuts, hint: hint ? safeText(hint) : null, scroll: 0 }, signal, resolve));
|
|
77
|
+
return new Promise(resolve => this.beginSetup({ kind: 'choice', waits: Boolean(waits), title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '', keys: shortcuts, hint: hint ? safeText(hint) : null, scroll: 0 }, signal, resolve));
|
|
74
78
|
}
|
|
75
79
|
setupItems() {
|
|
76
80
|
const state=this.setup;
|
|
77
81
|
const terms=(state?.query || '').toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
78
82
|
return (state?.items || []).filter(item=>terms.every(term=>`${item.label} ${item.id} ${item.description}`.toLocaleLowerCase().includes(term)));
|
|
79
83
|
}
|
|
80
|
-
setupInput({ title, description = '', value = '', secret = false, signal, validate } = {}) {
|
|
84
|
+
setupInput({ title, description = '', value = '', secret = false, signal, validate, waits = false } = {}) {
|
|
81
85
|
const clean = safeText(value).replace(/[\r\n\t]+/g, ' ');
|
|
82
86
|
if (Buffer.byteLength(clean) > 4096) throw new Error(t('Setup values support up to 4096 bytes.'));
|
|
83
|
-
return new Promise(resolve => this.beginSetup({ kind: 'input', title: safeText(title || t('Connection settings')), description: safeText(description), editor: new Composer(clean), secret: Boolean(secret), validate }, signal, resolve));
|
|
87
|
+
return new Promise(resolve => this.beginSetup({ kind: 'input', waits: Boolean(waits), title: safeText(title || t('Connection settings')), description: safeText(description), editor: new Composer(clean), secret: Boolean(secret), validate }, signal, resolve));
|
|
84
88
|
}
|
|
85
89
|
setupStatus({ title, description = '', signal } = {}) {
|
|
86
90
|
this.beginSetup({ kind: 'status', title: safeText(title || t('Connecting')), description: safeText(description) }, signal);
|
|
@@ -193,6 +197,8 @@ export class TerminalScreen {
|
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
schedule() {
|
|
200
|
+
// The state goes out even while an editor owns the terminal (Ctrl+G) or a selection freezes the screen.
|
|
201
|
+
if (!this.closed && (this.suspended || this.selecting)) this.syncAgentStatus();
|
|
196
202
|
if (this.closed || this.suspended || this.selecting || this.renderTimer) return;
|
|
197
203
|
this.renderTimer = setTimeout(() => { this.renderTimer = null; this.render(); }, 24);
|
|
198
204
|
}
|
|
@@ -710,6 +716,7 @@ export class TerminalScreen {
|
|
|
710
716
|
}
|
|
711
717
|
render(selectionEntry = false) {
|
|
712
718
|
if (this.closed || this.suspended || (this.selecting && !selectionEntry)) return;
|
|
719
|
+
this.syncAgentStatus();
|
|
713
720
|
const columns = Math.max(1, (this.output.columns || 80) - 1), rows = this.output.rows || 24;
|
|
714
721
|
if (columns < 23 || rows < 10) { this.draw([clip(t("Enlarge the terminal to 24×10"), columns)], null, columns, rows); return; }
|
|
715
722
|
if (this.setup && !this.permission) { this.renderSetup(columns, rows); return; }
|
|
@@ -803,9 +810,18 @@ export class TerminalScreen {
|
|
|
803
810
|
this.suspended = true; clearTimeout(this.renderTimer); this.renderTimer = null;
|
|
804
811
|
this.input.setRawMode?.(this.originalRaw); this.input.pause(); this.output.write(MOUSE_OFF + '\x1b[?2004l\x1b[?25h\x1b[?1049l');
|
|
805
812
|
}
|
|
806
|
-
resume() { this.input.setRawMode?.(true); this.input.resume(); this.suspended = false; this.previous = []; this.output.write('\x1b[?1049h\x1b[?2004h' + MOUSE_ON); this.render(); }
|
|
813
|
+
resume() { this.input.setRawMode?.(true); this.input.resume(); this.suspended = false; this.previous = []; this.agentStatus?.forgetTitle(); this.output.write('\x1b[?1049h\x1b[?2004h' + MOUSE_ON); this.render(); }
|
|
814
|
+
// The terminal title and, inside herdr, the pane's agent state follow what the screen shows (working, a question
|
|
815
|
+
// waiting for the human, or idle). Best-effort: see agent-status.mjs.
|
|
816
|
+
syncAgentStatus() {
|
|
817
|
+
if (!this.agentStatus) return;
|
|
818
|
+
const state = agentState({ busy: this.busy, permission: this.permission, setup: this.setup });
|
|
819
|
+
const message = state !== 'blocked' ? '' : this.permission ? t('Permission: {v0}', { v0: this.permission.name }) : (this.setup?.title || t('Waiting for your answer'));
|
|
820
|
+
this.agentStatus.update({ state, label: statusLabel({ name: this.metadata.name, cwd: this.metadata.cwd }), message, sessionId: this.metadata.sessionId, titles: !this.suspended });
|
|
821
|
+
}
|
|
807
822
|
close() {
|
|
808
823
|
if (this.closed) return; this.closed = true;
|
|
824
|
+
this.agentStatus?.close();
|
|
809
825
|
this.clearSetup();
|
|
810
826
|
this.permission?.finish(false); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
|
|
811
827
|
if (this.onData) this.input.off('data', this.onData); if (this.onResize) this.output.off('resize', this.onResize);
|