@milpa/comal 0.1.3 → 0.1.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/README.md +4 -2
- package/package.json +1 -1
- package/src/cli.mjs +29 -14
- package/src/connection-validation.mjs +49 -0
- package/src/i18n/es.mjs +65 -1
- package/src/model-controls.mjs +11 -4
- package/src/session-bus.mjs +16 -2
- package/src/tui/connection-options.mjs +111 -0
- package/src/tui/connection-transition.mjs +134 -0
- package/src/tui/controller.mjs +89 -31
- package/src/tui/screen.mjs +10 -1
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.1.
|
|
31
|
+
You can pin a version with `npx @milpa/comal@0.1.4` or `npm install --global @milpa/comal@0.1.4`. 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
|
|
|
@@ -56,6 +56,8 @@ comal --auth-provider codex --name resident
|
|
|
56
56
|
|
|
57
57
|
For APIs, set the key via the environment or your secrets manager. Do not paste it in the composer or as an argument. See https://comal.milpahq.com/academy/docs/modelos/ and https://comal.milpahq.com/academy/docs/login/ for profiles and providers.
|
|
58
58
|
|
|
59
|
+
In the TUI, `/provider` opens a searchable list of workspace profiles and login providers. `/models` lists models from the active provider; `/effort` offers the active model’s supported levels. Use ↑/↓ and Enter to select, or Esc to keep your draft. A pending selection stays distinct from the active connection. Comal checks model/provider candidates before replacing a working connection; failed checks offer explicit recovery without retrying automatically. A catalog check does not prove inference entitlement.
|
|
60
|
+
|
|
59
61
|
## Connect your Artifacts account
|
|
60
62
|
|
|
61
63
|
```sh
|
|
@@ -77,7 +79,7 @@ This removes the client; it keeps project sessions and user credentials. To disc
|
|
|
77
79
|
|
|
78
80
|
## Scope of this first distribution
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
Release acceptance runs the exact archive in empty user state on Linux, macOS arm64 and native Windows. Reports identify the archive hash, OS, Node runtime and checks; successful installation alone does not establish feature parity. On native Windows, cross-session messaging is explicitly unavailable, provider/cloud credential stores still need a Windows ACL implementation, and Bash/Grep require Unix tools. Use WSL for the complete Unix-oriented workflow; WSL and Intel macOS require separate runtime verification. Chrome Lab requires configuring your own SSH/CDP host. The package does not grant access to the author's lab.
|
|
81
83
|
|
|
82
84
|
The package belongs to the npm organization `@milpa`. `npx comal` corresponds to another project; use the full name `npx @milpa/comal`. A public license, native installers, and publisher signature will be resolved separately with the maintainer. Full documentation: https://comal.milpahq.com/academy/ .
|
|
83
85
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import { trustedContext } from './trusted-context.mjs';
|
|
|
33
33
|
import { contextSettings, resolveContextBudget, estimateRequest } from './compaction.mjs';
|
|
34
34
|
import { modelTimingSettings } from './model-deadline.mjs';
|
|
35
35
|
import { House, houseSnapshot } from './house.mjs';
|
|
36
|
+
import { Operation } from './operations.mjs';
|
|
37
|
+
import { validateConnectionCandidate } from './connection-validation.mjs';
|
|
36
38
|
import { PluginRuntime, parsePluginCommand } from './plugins.mjs';
|
|
37
39
|
import { registerWorkOperations, workProjection } from './work-plan.mjs';
|
|
38
40
|
import { evidenceOperations } from './work-state.mjs';
|
|
@@ -352,7 +354,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
352
354
|
peerBus=new SessionBus({store,getSessionId:()=>session.id,inbound:v.inbound,getMetadata:()=>({name:session.name,model:provider.model,effort:provider.effort || 'auto',authProvider:provider.authProvider,provider:v.demo?'demo':settings.provider,profile:provider.profileName || null,capabilities:sessionCapabilities()}),getAuthority:()=>({cwd,mode:policy.mode,allowed:[...policy.allowed],denied:[...policy.denied],ceilings:peerGuard.ceilings()}),getChain:()=>peerGuard.chain,onEvent});
|
|
353
355
|
const beforeModel=async({signal,source,turn,prompt}={})=>{
|
|
354
356
|
const context={provider,tools:tools.executor,record:event=>store.append(session.id,event),signal};
|
|
355
|
-
if(turn===1 && ['user','goal'].includes(source))await preparePeerRouting(prompt,{...context,bus:peerBus});
|
|
357
|
+
if(peerBus.availability?.available!==false && turn===1 && ['user','goal'].includes(source))await preparePeerRouting(prompt,{...context,bus:peerBus});
|
|
356
358
|
const delivered=await peerBus.drain();peerGuard.receive(delivered);
|
|
357
359
|
await preparePeerCapabilities(delivered,context);
|
|
358
360
|
for(const message of delivered)onEvent({type:'peer_message',from:message.from,message:message.content,request_id:message.request_id});return delivered.length+await subagents.incorporate({sessionId:session.id,signal});
|
|
@@ -361,11 +363,11 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
361
363
|
if (chromeWorkflow) await chromeWorkflow.finishResult(final, context);
|
|
362
364
|
await completePeerReplies(final,{...context,requests:peerGuard.messages,store,sessionId:session.id,tools:tools.executor,record:event=>store.append(session.id,event),onEvent});
|
|
363
365
|
};
|
|
364
|
-
for(const definition of peerToolDefinitions)registry.add({...definition,native:true,effects:{workspace:'none',external:definition.name==='SendMessage'?'write':'none',process:'none',authority:'none'},run:async(input,options)=>JSON.stringify(definition.name==='ListAgents'?await peerBus.list(input):await peerBus.send(input,options))});
|
|
366
|
+
for(const definition of (peerBus.availability?.available===false?[]:peerToolDefinitions))registry.add({...definition,native:true,effects:{workspace:'none',external:definition.name==='SendMessage'?'write':'none',process:'none',authority:'none'},run:async(input,options)=>JSON.stringify(definition.name==='ListAgents'?await peerBus.list(input):await peerBus.send(input,options))});
|
|
365
367
|
let providerOperations=[];
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
house.registry.replace(providerOperations,definitions);providerOperations=definitions.map(definition=>definition.name);
|
|
368
|
+
const providerFeaturesFor=next=>providerFeatureDefinitions(next).map(definition=>new Operation({...definition,effects:{workspace:definition.name==='AnalyzeMedia'?'read':'none',external:definition.name==='ProviderCapabilities'?'none':'read',process:'none',authority:'none'},run:(input,options)=>executeProviderFeature(definition.name,input,{provider:next,workspace:tools,record:event=>store.append(session.id,event),signal:options.signal,observe:options.observe,invoke:(request,execute)=>activeGoalBudget?activeGoalBudget.complete(request,execute):execute(request)})}));
|
|
369
|
+
const publishProviderFeatures=(definitions=providerFeaturesFor(provider))=>{
|
|
370
|
+
house.registry.replace(providerOperations,definitions);providerOperations=definitions.map(definition=>definition.contract.name);
|
|
369
371
|
};
|
|
370
372
|
publishProviderFeatures();
|
|
371
373
|
const combinedTools = tools.executor;
|
|
@@ -641,8 +643,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
641
643
|
{ 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}); } },
|
|
642
644
|
{ 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})=>{
|
|
643
645
|
if(!model.trim() || /\s/.test(model))throw new Error(t("Use /model ID, without spaces."));
|
|
644
|
-
await replaceProvider({...settings,model,effort:'auto'},{refresh:true});
|
|
645
|
-
return t("Model: {v0} · effort {v1} · same session", {v0: provider.model, v1: provider.effort || 'auto'});
|
|
646
|
+
const verification=await replaceProvider({...settings,model,effort:'auto'},{refresh:true,validate:true});
|
|
647
|
+
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.'):'');
|
|
646
648
|
}},
|
|
647
649
|
{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})=>{
|
|
648
650
|
await replaceProvider({...settings,model:provider.model,effort:normalizeEffort(effort)});
|
|
@@ -653,8 +655,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
653
655
|
if(v.demo)throw new Error(t("The demo does not allow changing provider."));
|
|
654
656
|
const selected=profiles.profiles.find(item=>item.name===profile);
|
|
655
657
|
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};
|
|
656
|
-
await replaceProvider(config);
|
|
657
|
-
return t("Provider: {v0} · {v1} · {v2}", {v0: selected?.name || provider.authProvider, v1: settings.provider, v2: provider.model});
|
|
658
|
+
const verification=await replaceProvider(config,{validate:true});
|
|
659
|
+
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.'):'');
|
|
658
660
|
}},
|
|
659
661
|
{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_]*$'}},required:['provider'],additionalProperties:false},effects:{workspace:'none',external:'unknown',process:'none',authority:'credential_control'},run:async({provider:target,apiKeyEnv})=>loginControl(target+(apiKeyEnv?' --api-key-env '+apiKeyEnv:''))},
|
|
660
662
|
{name:'SessionLogout',description:'Human removes Comal credentials; does not revoke the remote account.',input_schema:{type:'object',properties:{provider:{type:'string',enum:AUTH_PROVIDERS.map(item=>item.id)}},required:['provider'],additionalProperties:false},effects:{workspace:'none',external:'none',process:'none',authority:'credential_control'},run:async({provider:target})=>loginControl(target,true)},
|
|
@@ -672,10 +674,12 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
672
674
|
} });
|
|
673
675
|
const changeMode = mode => humanTools.execute('SessionMode', { mode });
|
|
674
676
|
let discoveredModels = [], discoveredModelKey = '', discoveredSessions = [], discoveredPeers = [];
|
|
677
|
+
let authStatus={};
|
|
678
|
+
const refreshConnections=async()=>{const statuses=await Promise.allSettled(AUTH_PROVIDERS.map(item=>authManager.status(item.id)));authStatus=Object.fromEntries(AUTH_PROVIDERS.map((item,index)=>[item.id,statuses[index].status==='fulfilled'?statuses[index].value.status:'unknown']));};
|
|
675
679
|
const modelDiscoveryKey = () => `${settings.provider}:${provider.baseURL || provider.url || ''}:${provider.model}`;
|
|
676
680
|
const discovery = () => ({
|
|
677
681
|
mode: policy.mode, effortLevels: modelCapabilities?.effort?.levels || ['auto'],
|
|
678
|
-
capabilities: modelCapabilities, profiles: profiles.profiles.map(({name}) => ({name})),
|
|
682
|
+
capabilities: modelCapabilities, profiles: profiles.profiles.map(({name,provider,model,authProvider}) => ({name,provider,model,authProvider})),authStatus,
|
|
679
683
|
authProviders: AUTH_PROVIDERS.map(({id,label,method,apiKeyEnv,experimental}) => ({id,label,description: method==='device_code' ? t('Browser sign-in') + (experimental ? ' · '+t('Experimental') : '') : t('Uses environment variable {name}',{name:apiKeyEnv})})),
|
|
680
684
|
mcpServers: mcpRuntime.status().servers.map(({name,status}) => ({name,status})),
|
|
681
685
|
plugins: plugins.status().plugins.map(({id,state,operations,builtin}) => ({id,state,operations,builtin})),
|
|
@@ -683,21 +687,29 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
683
687
|
models: discoveredModelKey === modelDiscoveryKey() ? discoveredModels : [{id:provider.model}],
|
|
684
688
|
sessions: discoveredSessions, peers: discoveredPeers,agents:[...(ui?.subagentBlocks.values() || [])].map(child=>({id:child.run_id || child.id,description:`${child.runner_state || child.state} / ${child.turn_state || '?'} / ${child.work_state || 'unjudged'} · ${child.description || ''}`})),
|
|
685
689
|
});
|
|
686
|
-
const metadata = () => ({cwd,sessionId:session.id,name:session.name,model:provider.model,mode:policy.mode,provider:v.demo?'demo':settings.provider,profile:provider.profileName,effort:provider.effort || 'auto',contextBudget:contextBudget(modelCapabilities),modelCapabilities,authProvider:provider.authProvider,workflow:chromeWorkflow?`chrome:${chromeWorkflow.task.project}`:null});
|
|
687
|
-
const replaceProvider = async (config,{refresh=false}={}) => {
|
|
690
|
+
const metadata = () => ({cwd,sessionId:session.id,name:session.name,model:provider.model,mode:policy.mode,provider:v.demo?'demo':settings.provider,profile:provider.profileName,effort:provider.effort || 'auto',contextBudget:contextBudget(modelCapabilities),modelCapabilities,authProvider:provider.authProvider,peerAvailability:peerBus.availability,workflow:chromeWorkflow?`chrome:${chromeWorkflow.task.project}`:null});
|
|
691
|
+
const replaceProvider = async (config,{refresh=false,validate=false}={}) => {
|
|
688
692
|
if(v.demo)throw new Error(t("Change model/effort in a session with a real provider."));
|
|
689
693
|
modelController=new AbortController();const wasBusy=ui?.busy;ui?.setBusy(true);
|
|
690
694
|
const signal=controller?.signal?AbortSignal.any([controller.signal,modelController.signal]):modelController.signal;
|
|
691
695
|
try {
|
|
692
696
|
const next=await instantiate({...config,signal,maxTokens:contextConfig.maxTokens,...(config.provider==='openai'?timing:{})});
|
|
693
697
|
if(typeof next.discoverModel==='function')await next.discoverModel({signal});
|
|
698
|
+
const verification=validate?await validateConnectionCandidate(next,{signal,explicitModel:typeof config.model==='string' && Boolean(config.model.trim())}):null;
|
|
694
699
|
await setProviderEffort(next,config.effort || 'auto',{signal});
|
|
695
700
|
const report=await inspectModel(next,{signal,force:refresh});report.effort.selected=next.effort;
|
|
696
701
|
effectiveContextWindow(report);
|
|
702
|
+
if(verification)report.connectionValidation=verification;
|
|
703
|
+
const features=providerFeaturesFor(next);
|
|
704
|
+
for(const feature of features)if(house.registry.get(feature.contract.name) && !providerOperations.includes(feature.contract.name))throw Object.assign(new Error('Provider operation conflicts with an existing contract.'),{code:'PROVIDER_OPERATION_CONFLICT'});
|
|
697
705
|
const identityChanged=provider.model!==next.model || settings.provider!==config.provider || (provider.baseURL || String(provider.url))!==(next.baseURL || String(next.url));
|
|
698
706
|
signal?.throwIfAborted();await persistProvider(next,config,report);
|
|
699
|
-
provider=next;settings={...config,model:next.model};modelCapabilities=report;publishProviderFeatures();agent.providerName=settings.provider;agent.compactor.contextWindow=effectiveContextWindow(report);if(identityChanged)agent.compactor.factor=1;
|
|
700
|
-
|
|
707
|
+
provider=next;settings={...config,model:next.model};modelCapabilities=report;publishProviderFeatures(features);agent.providerName=settings.provider;agent.compactor.contextWindow=effectiveContextWindow(report);if(identityChanged)agent.compactor.factor=1;
|
|
708
|
+
// The durable provider row is the commit boundary. Cancellation after it
|
|
709
|
+
// cannot truthfully report that the previous connection was retained.
|
|
710
|
+
ui?.updateMetadata(metadata());
|
|
711
|
+
try{await peerBus.setState(controller?'busy':'idle');}catch{ui?.notice(t('Connection applied; live-session presence could not be refreshed.'),true);}
|
|
712
|
+
return verification;
|
|
701
713
|
}finally{modelController=null;if(ui)ui.setBusy(wasBusy);}
|
|
702
714
|
};
|
|
703
715
|
const loginControl=async(name,logout=false)=>{
|
|
@@ -711,6 +723,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
711
723
|
finally{authController=null;if(ui)ui.setBusy(wasBusy);}
|
|
712
724
|
};
|
|
713
725
|
const backend = {
|
|
726
|
+
refreshConnections,
|
|
714
727
|
async listModels({signal}={}) {
|
|
715
728
|
const key=modelDiscoveryKey();
|
|
716
729
|
const models=await listProviderModels(provider,{signal});
|
|
@@ -831,6 +844,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
831
844
|
};
|
|
832
845
|
try {
|
|
833
846
|
await peerBus.start();
|
|
847
|
+
if(peerBus.availability?.available===false){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');}
|
|
848
|
+
await refreshConnections();
|
|
834
849
|
await saveProvider();
|
|
835
850
|
await lifecycle.execute('PluginInitialize', { trust: Boolean(v['trust-plugins']) }, { signal: startupSignal });
|
|
836
851
|
await lifecycle.execute('McpInitialize', { trust: Boolean(v['trust-mcp']) }, { signal: startupSignal });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {listProviderModels} from './model-controls.mjs';
|
|
2
|
+
|
|
3
|
+
const NETWORK_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'EAI_AGAIN', 'ECONNRESET', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT']);
|
|
4
|
+
function rejected(code, message, status) {
|
|
5
|
+
return Object.assign(new Error(message), {code, ...(status ? {status} : {})});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A catalog response proves only the read-only connection check. It cannot prove
|
|
9
|
+
// that inference, a particular model or paid/account entitlements will succeed.
|
|
10
|
+
// The candidate remains detached; this helper neither persists nor selects it.
|
|
11
|
+
export async function validateConnectionCandidate(provider, {signal, explicitModel = false, timeoutMs = 10000} = {}) {
|
|
12
|
+
if (!provider || typeof provider.model !== 'string' || !provider.model.trim()) throw rejected('CONNECTION_MODEL_REQUIRED', 'Choose a model before applying this connection.');
|
|
13
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) throw new TypeError('Connection validation timeout must be between 1 and 30000 milliseconds.');
|
|
14
|
+
if (typeof provider.listModels !== 'function' && !(typeof provider.fetch === 'function' && (provider.baseURL || provider.url))) {
|
|
15
|
+
throw rejected('CONNECTION_CATALOG_UNSUPPORTED', 'This provider cannot check its model catalog.');
|
|
16
|
+
}
|
|
17
|
+
const timeout = AbortSignal.timeout(timeoutMs), bounded = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
18
|
+
let onAbort;
|
|
19
|
+
try {
|
|
20
|
+
bounded.throwIfAborted();
|
|
21
|
+
const interrupted = new Promise((resolve, reject) => {
|
|
22
|
+
onAbort = () => reject(rejected(timeout.aborted && !signal?.aborted ? 'CONNECTION_TIMEOUT' : 'ABORT_ERR', timeout.aborted && !signal?.aborted ? 'Connection validation timed out.' : 'Connection validation cancelled.'));
|
|
23
|
+
bounded.addEventListener('abort', onAbort, {once: true});
|
|
24
|
+
});
|
|
25
|
+
const models = await Promise.race([listProviderModels(provider, {signal: bounded}), interrupted]);
|
|
26
|
+
bounded.throwIfAborted();
|
|
27
|
+
return {verified: true, catalogVerified: true, modelAdvertised: models.some(item => item.id === provider.model.replace(/^models\//, '')),
|
|
28
|
+
compatibilityFallback: false, source: 'model_catalog', inferenceVerified: false};
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (signal?.aborted) throw Object.assign(rejected('ABORT_ERR', 'Connection validation cancelled.'), {name: 'AbortError'});
|
|
31
|
+
if (timeout.aborted || error?.name === 'TimeoutError' || ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT'].includes(error?.code)) {
|
|
32
|
+
throw Object.assign(rejected('CONNECTION_TIMEOUT', 'Connection validation timed out.'), {name: 'TimeoutError'});
|
|
33
|
+
}
|
|
34
|
+
// Native adapters may keep the HTTP code in a structured diagnostic rather
|
|
35
|
+
// than error.status. Read the integer only, never their upstream payload.
|
|
36
|
+
const status = [error?.status, error?.details?.http_status].find(value => Number.isInteger(value) && value >= 100 && value <= 599);
|
|
37
|
+
if (explicitModel === true && [404, 405].includes(status)) {
|
|
38
|
+
return {verified: false, catalogVerified: false, modelAdvertised: null, compatibilityFallback: true,
|
|
39
|
+
source: 'catalog_unavailable', inferenceVerified: false, metadataStatus: status};
|
|
40
|
+
}
|
|
41
|
+
const networkCode = [error?.code, error?.cause?.code].find(code => NETWORK_CODES.has(code));
|
|
42
|
+
if (networkCode) throw rejected(networkCode, 'The model server could not be reached.');
|
|
43
|
+
if ([401, 403].includes(status) || ['PROVIDER_AUTH_REQUIRED', 'AUTH_REQUIRED', 'UNAUTHENTICATED'].includes(error?.code)) throw rejected('CONNECTION_AUTH_REQUIRED', 'The model server refused authentication. Sign in or choose another connection.', status);
|
|
44
|
+
if (status) throw rejected('CONNECTION_CATALOG_HTTP', 'The model catalog is unavailable. The active connection was not changed.', status);
|
|
45
|
+
throw rejected('CONNECTION_VALIDATION_FAILED', 'The model catalog could not be checked. The active connection was not changed.');
|
|
46
|
+
} finally {
|
|
47
|
+
if (onAbort) bounded.removeEventListener('abort', onAbort);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/i18n/es.mjs
CHANGED
|
@@ -245,5 +245,69 @@ export default Object.freeze({
|
|
|
245
245
|
"Provider: {provider}. The selected model applies to the next call; active work keeps its model.": "Proveedor: {provider}. El modelo elegido se aplica a la siguiente llamada; el trabajo activo conserva su modelo.",
|
|
246
246
|
"No options found. Check the provider or use /model ID.": "No hay opciones. Revisa el proveedor o usa /model ID.",
|
|
247
247
|
"The provider changed. Open the model list again.": "El proveedor cambió. Abre de nuevo la lista de modelos.",
|
|
248
|
-
"Selection queued. It will apply after the current turn.": "Selección en cola. Se aplicará al terminar el turno actual."
|
|
248
|
+
"Selection queued. It will apply after the current turn.": "Selección en cola. Se aplicará al terminar el turno actual.",
|
|
249
|
+
"Signed in": "Sesión iniciada",
|
|
250
|
+
"Credentials configured": "Credenciales configuradas",
|
|
251
|
+
"Not signed in": "Sin sesión iniciada",
|
|
252
|
+
"Sign-in expired": "Inicio de sesión vencido",
|
|
253
|
+
"Authentication status unknown": "Estado de autenticación desconocido",
|
|
254
|
+
"Workspace profile": "Perfil del espacio de trabajo",
|
|
255
|
+
"Choose a provider": "Elige un proveedor",
|
|
256
|
+
"Choose a workspace profile or login provider. The connection is checked before replacing the active provider.": "Elige un perfil del espacio de trabajo o un proveedor de inicio de sesión. Se comprueba la conexión antes de reemplazar el proveedor activo.",
|
|
257
|
+
"{count} input tokens": "{count} tokens de entrada",
|
|
258
|
+
"Choose effort": "Elige el esfuerzo",
|
|
259
|
+
"This model does not announce configurable effort. Auto uses the provider default.": "Este modelo no anuncia esfuerzo configurable. Auto usa el valor predeterminado del proveedor.",
|
|
260
|
+
"Only levels announced by the active model are offered. Auto uses the provider default.": "Solo se ofrecen los niveles anunciados por el modelo activo. Auto usa el valor predeterminado del proveedor.",
|
|
261
|
+
"Effort support is unknown. Only auto is available; use /capabilities refresh to check.": "El soporte de esfuerzo es desconocido. Solo está disponible auto; usa /capabilities refresh para comprobarlo.",
|
|
262
|
+
"Current effort {effort} is not in the announced levels.": "El esfuerzo actual {effort} no está entre los niveles anunciados.",
|
|
263
|
+
"Current effort is unknown. Auto is the safe default.": "El esfuerzo actual es desconocido. Auto es el valor predeterminado seguro.",
|
|
264
|
+
"Provider default; no explicit effort is sent.": "Valor predeterminado del proveedor; no se envía un esfuerzo explícito.",
|
|
265
|
+
"Announced by the active model": "Anunciado por el modelo activo",
|
|
266
|
+
"Connection change queued. The active connection stays in use.": "Cambio de conexión en cola. Se sigue usando la conexión activa.",
|
|
267
|
+
"Checking the selected connection before applying it.": "Comprobando la conexión elegida antes de aplicarla.",
|
|
268
|
+
"Connection change applied.": "Cambio de conexión aplicado.",
|
|
269
|
+
"Connection change cancelled.": "Cambio de conexión cancelado.",
|
|
270
|
+
"Connection change failed. The previous connection is still active.": "Falló el cambio de conexión. La conexión anterior sigue activa.",
|
|
271
|
+
"Connection change could not be confirmed. Check the active connection before trying again.": "No se pudo confirmar el cambio de conexión. Revisa la conexión activa antes de volver a intentarlo.",
|
|
272
|
+
"Connection change not applied": "Cambio de conexión no aplicado",
|
|
273
|
+
"Connection status needs review": "El estado de conexión requiere revisión",
|
|
274
|
+
"Keep the active connection": "Conservar la conexión activa",
|
|
275
|
+
"Return to your draft without changing the connection.": "Vuelve a tu borrador sin cambiar la conexión.",
|
|
276
|
+
"Retry this selection": "Reintentar esta selección",
|
|
277
|
+
"Sign in to the selected provider": "Iniciar sesión con el proveedor elegido",
|
|
278
|
+
"Choose another connection": "Elegir otra conexión",
|
|
279
|
+
"Open the provider and workspace profile selector.": "Abre el selector de proveedores y perfiles del espacio de trabajo.",
|
|
280
|
+
"Sign in or check the configured credential environment variable.": "Inicia sesión o revisa la variable de entorno configurada para las credenciales.",
|
|
281
|
+
"The model server timed out.": "Se agotó el tiempo de espera del servidor del modelo.",
|
|
282
|
+
"The model server could not be reached.": "No se pudo contactar al servidor del modelo.",
|
|
283
|
+
"The active model does not support this setting.": "El modelo activo no admite esta configuración.",
|
|
284
|
+
"The change could not be verified.": "No se pudo verificar el cambio.",
|
|
285
|
+
"Checking /{command} {target} · active: {model}": "Comprobando /{command} {target} · activo: {model}",
|
|
286
|
+
"Queued /{command} {target} · active: {model}": "En cola /{command} {target} · activo: {model}",
|
|
287
|
+
"Connection applied; live-session presence could not be refreshed.": "Conexión aplicada; no se pudo actualizar la presencia de la sesión en vivo.",
|
|
288
|
+
"Model catalog unavailable; the active connection is unverified. No inference was sent.": "Catálogo de modelos no disponible; la conexión activa no está verificada. No se envió ninguna inferencia.",
|
|
289
|
+
"Cross-session messaging is unavailable on this platform. Local work remains available.": "Los mensajes entre sesiones no están disponibles en esta plataforma. El trabajo local sigue disponible.",
|
|
290
|
+
"Attach up to two images per message.": "Adjunta hasta dos imágenes por mensaje.",
|
|
291
|
+
"The session changed; the image was not attached.": "La sesión cambió; no se adjuntó la imagen.",
|
|
292
|
+
"Checking vision support…": "Comprobando soporte de visión…",
|
|
293
|
+
"{count} image(s) attached · Backspace removes a label": "{count} imagen(es) adjunta(s) · Retroceso elimina una etiqueta",
|
|
294
|
+
"This model cannot receive images or vision is unverified. Switch model or remove the image before sending.": "Este modelo no puede recibir imágenes o la visión no está verificada. Cambia de modelo o elimina la imagen antes de enviar.",
|
|
295
|
+
"Attaching image…": "Adjuntando imagen…",
|
|
296
|
+
"The session changed; the message was not sent.": "La sesión cambió; no se envió el mensaje.",
|
|
297
|
+
"Image not sent: the current model does not support verified vision.": "Imagen no enviada: el modelo actual no tiene soporte de visión verificado.",
|
|
298
|
+
"Use a plain message with images; commands do not accept image attachments.": "Usa un mensaje normal con imágenes; los comandos no aceptan imágenes adjuntas.",
|
|
299
|
+
"Choose a connection": "Elige una conexión",
|
|
300
|
+
"Setup values support up to 4096 bytes.": "Los valores de configuración admiten hasta 4096 bytes.",
|
|
301
|
+
"Connection settings": "Configuración de conexión",
|
|
302
|
+
"Connecting": "Conectando",
|
|
303
|
+
"Use at most 4096 bytes.": "Usa un máximo de 4096 bytes.",
|
|
304
|
+
"Check the value and try again.": "Revisa el valor e inténtalo de nuevo.",
|
|
305
|
+
"Ctrl+C cancels": "Ctrl+C cancela",
|
|
306
|
+
"Ctrl+C exits": "Ctrl+C sale",
|
|
307
|
+
"↑/↓ choose · Enter continues · Esc back": "↑/↓ elige · Enter continúa · Esc vuelve",
|
|
308
|
+
"Enter continues · Esc back": "Enter continúa · Esc vuelve",
|
|
309
|
+
"Esc back": "Esc vuelve",
|
|
310
|
+
"Working…": "Trabajando…",
|
|
311
|
+
"Checking…": "Comprobando…",
|
|
312
|
+
"The draft changed while preparing the image. Press Enter again to send the current draft.": "El borrador cambió mientras se preparaba la imagen. Pulsa Enter de nuevo para enviar el borrador actual."
|
|
249
313
|
});
|
package/src/model-controls.mjs
CHANGED
|
@@ -16,10 +16,17 @@ function headers(provider) {
|
|
|
16
16
|
? { 'x-api-key': provider.apiKey, 'anthropic-version': '2023-06-01' }
|
|
17
17
|
: { 'x-goog-api-key': provider.apiKey };
|
|
18
18
|
}
|
|
19
|
-
async function json(provider, url, { signal, ...options } = {}) {
|
|
19
|
+
async function json(provider, url, { signal, throwHttp = false, ...options } = {}) {
|
|
20
20
|
const timeout = AbortSignal.timeout(3000);
|
|
21
21
|
const response = await provider.fetch(url, { ...options, headers: { 'content-type': 'application/json', ...headers(provider) }, signal: signal ? AbortSignal.any([signal, timeout]) : timeout, redirect: 'error' });
|
|
22
|
-
if (!response.ok) {
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
try { await response.body?.cancel(); } catch {}
|
|
24
|
+
// Optional capability probes can still be absent. A user-requested catalog
|
|
25
|
+
// check must distinguish unsupported routes from refused authentication or
|
|
26
|
+
// an unavailable server, without retaining an error body or request URL.
|
|
27
|
+
if (throwHttp) throw Object.assign(new Error('The model catalog request was refused.'), {code: 'MODEL_DISCOVERY_HTTP', status: response.status});
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
23
30
|
const reader = response.body?.getReader();
|
|
24
31
|
if (!reader) return null;
|
|
25
32
|
let text = '', size = 0; const decoder = new TextDecoder();
|
|
@@ -43,8 +50,8 @@ export async function listProviderModels(provider, { signal } = {}) {
|
|
|
43
50
|
do {
|
|
44
51
|
const url = new URL(base(provider));
|
|
45
52
|
if (next) url.searchParams.set(protocol(provider) === 'gemini' ? 'pageToken' : 'after_id', next);
|
|
46
|
-
const data = await json(provider, url.href, { signal });
|
|
47
|
-
if (!data) throw Object.assign(new Error('The server did not
|
|
53
|
+
const data = await json(provider, url.href, { signal, throwHttp: true });
|
|
54
|
+
if (!data || (!Array.isArray(data.data) && !Array.isArray(data.models))) throw Object.assign(new Error('The server did not return a model catalog.'), { code: 'MODEL_DISCOVERY_UNAVAILABLE' });
|
|
48
55
|
for (const item of Array.isArray(data.data) ? data.data : Array.isArray(data.models) ? data.models : []) { const safe = safeModel(item); if (safe && !models.some(model => model.id === safe.id)) models.push(safe); }
|
|
49
56
|
next = protocol(provider) === 'gemini' ? data.nextPageToken : data.has_more ? data.last_id : '';
|
|
50
57
|
} while (next && ++pages < 8 && models.length < 1024);
|
package/src/session-bus.mjs
CHANGED
|
@@ -60,13 +60,18 @@ function mailbox(rows) {
|
|
|
60
60
|
/** Local, same-user IPC. The private registry authenticates live instances; it is
|
|
61
61
|
* not a sandbox against other programs already running as this OS user. */
|
|
62
62
|
export class SessionBus {
|
|
63
|
-
constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL } = {}) {
|
|
63
|
+
constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL, platform = process.platform } = {}) {
|
|
64
64
|
if (!store || typeof getSessionId !== 'function') throw new TypeError('SessionBus requiere store y getSessionId.');
|
|
65
65
|
if (inbound !== undefined && !['accept', 'hold', 'refuse'].includes(inbound)) throw fail('PEER_INBOUND_INVALID', 'Usa accept, hold o refuse.');
|
|
66
66
|
Object.assign(this, { store, getSessionId, getMetadata, getAuthority, getChain, directory: path.resolve(directory), onPending, onEvent, timeoutMs, ttlMs: Math.min(TTL, Math.max(10, ttlMs)) });
|
|
67
|
+
this.availability = Object.freeze(platform === 'win32' ? { available: false, transport: 'none', code: 'PEER_PLATFORM_UNSUPPORTED', reason: 'Cross-session messaging is unavailable on native Windows; the current IPC requires POSIX private directories and Unix sockets.' } : { available: true, transport: 'unix' });
|
|
67
68
|
this.state = 'idle'; this.inbound = inbound || 'accept'; this.inboundOverride = inbound; this.inboundConfigured = new Set(); this.sockets = new Set(); this._starting = undefined;
|
|
68
69
|
}
|
|
70
|
+
_requireSupported() {
|
|
71
|
+
if (!this.availability.available) throw Object.assign(fail(this.availability.code, this.availability.reason), { availability: this.availability });
|
|
72
|
+
}
|
|
69
73
|
async _directory() {
|
|
74
|
+
this._requireSupported();
|
|
70
75
|
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
71
76
|
const stat = await lstat(this.directory);
|
|
72
77
|
if (!stat.isDirectory() || stat.isSymbolicLink() || (uid() !== undefined && stat.uid !== uid()) || (stat.mode & 0o077)) throw fail('PEER_DIRECTORY_UNSAFE', 'El directorio de peers debe pertenecer al usuario y tener permisos 0700, sin symlinks.');
|
|
@@ -128,6 +133,10 @@ export class SessionBus {
|
|
|
128
133
|
throw fail('PEER_SESSION_IN_USE', 'La sesión está iniciando en otra instancia.');
|
|
129
134
|
}
|
|
130
135
|
async start() {
|
|
136
|
+
if (!this.availability.available) {
|
|
137
|
+
if (!this._unavailableReported) { this._unavailableReported = true; this._event({ type: 'peer_unavailable', ...this.availability }); }
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
131
140
|
if (this._starting) return this._starting;
|
|
132
141
|
this._starting = this._start();
|
|
133
142
|
try { return await this._starting; } finally { this._starting = undefined; }
|
|
@@ -205,6 +214,7 @@ export class SessionBus {
|
|
|
205
214
|
}
|
|
206
215
|
async send(input = {}, { signal } = {}) { return this._send(input, { signal }); }
|
|
207
216
|
async _send(input = {}, { signal, kind = 'message', chain, inheritedAuthority } = {}) {
|
|
217
|
+
this._requireSupported();
|
|
208
218
|
await this.start(); if (signal?.aborted) throw fail('ABORT_ERR', 'Envío cancelado.');
|
|
209
219
|
const { to, message, request_id = randomUUID(), reply_to, notify_when_idle = false, required_capabilities = [] } = input;
|
|
210
220
|
if ((typeof message !== 'string' || !message.trim()) && !notify_when_idle) throw fail('PEER_MESSAGE_REQUIRED', 'Se requiere un mensaje o notify_when_idle.');
|
|
@@ -301,11 +311,13 @@ export class SessionBus {
|
|
|
301
311
|
return result;
|
|
302
312
|
}
|
|
303
313
|
async pending() {
|
|
314
|
+
if (!this.availability.available) return { count: 0, held: 0, total: 0, ...this.availability };
|
|
304
315
|
const id = this.getSessionId(); this._paths(id); const state = mailbox((await this.store.load(id)).rows);
|
|
305
316
|
const held = state.live.filter(item => item.held && !state.released.has(item.message_id)).length;
|
|
306
317
|
return { count: this.inbound==='accept' ? state.live.length - held : 0, held, total: state.live.length };
|
|
307
318
|
}
|
|
308
319
|
async drain() {
|
|
320
|
+
if (!this.availability.available) return [];
|
|
309
321
|
const id = this.getSessionId(); this._paths(id);
|
|
310
322
|
if (this.inbound !== 'accept') return [];
|
|
311
323
|
return this.store.workTransaction(id, async () => {
|
|
@@ -322,6 +334,7 @@ export class SessionBus {
|
|
|
322
334
|
});
|
|
323
335
|
}
|
|
324
336
|
async releaseHeld(id = 'all') {
|
|
337
|
+
this._requireSupported();
|
|
325
338
|
const sessionId = this.getSessionId(); this._paths(sessionId);
|
|
326
339
|
const count = await this.store.workTransaction(sessionId, async () => {
|
|
327
340
|
const state = mailbox((await this.store.load(sessionId)).rows); let released = 0;
|
|
@@ -331,6 +344,7 @@ export class SessionBus {
|
|
|
331
344
|
if (count && this.inbound === 'accept') this._signal(); return { released: count };
|
|
332
345
|
}
|
|
333
346
|
async setInbound(value) {
|
|
347
|
+
this._requireSupported();
|
|
334
348
|
if (!['accept', 'hold', 'refuse'].includes(value)) throw fail('PEER_INBOUND_INVALID', 'Usa accept, hold o refuse.');
|
|
335
349
|
const id = this.getSessionId(); this._paths(id);
|
|
336
350
|
const previous = this._inboundSetting || Promise.resolve();
|
|
@@ -350,7 +364,7 @@ export class SessionBus {
|
|
|
350
364
|
if (!['busy', 'idle'].includes(value)) throw fail('PEER_STATE_INVALID', 'Usa busy o idle.');
|
|
351
365
|
this.state = value; if (this.server) await this._publish();
|
|
352
366
|
if (value === 'idle' && this.server && this.inbound === 'accept') await this._notifyIdle();
|
|
353
|
-
return { state: this.state };
|
|
367
|
+
return { state: this.state, ...(!this.availability.available ? this.availability : {}) };
|
|
354
368
|
}
|
|
355
369
|
async _notifyIdle() {
|
|
356
370
|
if (this.notifying || !this.server) return; this.notifying = true;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { t } from '../i18n/index.mjs';
|
|
2
|
+
import { EFFORT_LEVELS } from '../model-controls.mjs';
|
|
3
|
+
import { safeText } from './text.mjs';
|
|
4
|
+
|
|
5
|
+
// These snapshots are presentation data, not credentials or executable command
|
|
6
|
+
// fragments. Never change an identifier while sanitizing its display label.
|
|
7
|
+
const controls = /[\s\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u;
|
|
8
|
+
const id = value => typeof value === 'string' && value.length > 0 && value.length <= 256 && !controls.test(value) ? value : null;
|
|
9
|
+
const text = (value, length = 240) => typeof value === 'string'
|
|
10
|
+
? safeText(value).replace(/[\u202a-\u202e\u2066-\u2069]/gu, '').replace(/\s+/gu, ' ').trim().slice(0, length) : '';
|
|
11
|
+
const list = values => Array.isArray(values) ? values.slice(0, 1024) : [];
|
|
12
|
+
const join = values => values.filter(Boolean).join(' · ');
|
|
13
|
+
const unique = values => [...new Map(values.map(value => [value.id, value])).values()];
|
|
14
|
+
|
|
15
|
+
function authState(provider, discovery) {
|
|
16
|
+
const snapshots = discovery.authStatus;
|
|
17
|
+
const snapshot = Array.isArray(snapshots) ? snapshots.find(item => item?.provider === provider.id)
|
|
18
|
+
: snapshots && typeof snapshots === 'object' ? snapshots[provider.id] : null;
|
|
19
|
+
const value = provider.status ?? (typeof snapshot === 'string' ? snapshot : snapshot?.status);
|
|
20
|
+
const states = { authenticated: t('Signed in'), configured: t('Credentials configured'), logged_out: t('Not signed in'), expired: t('Sign-in expired') };
|
|
21
|
+
return typeof value === 'string' && Object.hasOwn(states, value) ? states[value] : t('Authentication status unknown');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function authItems(discovery) {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
return list(discovery.authProviders).flatMap(value => {
|
|
27
|
+
const provider = typeof value === 'string' ? { id: value } : value;
|
|
28
|
+
const key = id(provider?.id);
|
|
29
|
+
if (!key || seen.has(key)) return [];
|
|
30
|
+
seen.add(key);
|
|
31
|
+
return [{ id: key, label: text(provider.label, 100) || key, description: join([key, authState(provider, discovery), text(provider.description)]), kind: 'auth' }];
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function providerItems(discovery) {
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const profiles = list(discovery.profiles).flatMap(value => {
|
|
38
|
+
const profile = typeof value === 'string' ? { name: value } : value;
|
|
39
|
+
const key = id(profile?.name);
|
|
40
|
+
if (!key || seen.has(key)) return [];
|
|
41
|
+
seen.add(key);
|
|
42
|
+
const authProvider = id(profile.authProvider);
|
|
43
|
+
const auth = authProvider && list(discovery.authProviders).find(value => value?.id === authProvider);
|
|
44
|
+
return [{ id: key, label: key, description: join([t('Workspace profile'), text(profile.provider || profile.protocol, 40), text(profile.model, 100), authProvider ? authState(auth || { id: authProvider }, discovery) : t('Authentication status unknown'), text(profile.description)]), kind: 'profile' }];
|
|
45
|
+
});
|
|
46
|
+
// /provider resolves configured names first; the picker must show exactly the
|
|
47
|
+
// same choice instead of a second, misleading builtin with the same argument.
|
|
48
|
+
return [...profiles, ...authItems(discovery).filter(value => !seen.has(value.id))];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build an inert, searchable connection menu from an already-read snapshot.
|
|
52
|
+
* Opening this helper performs no auth, filesystem, model or network requests.
|
|
53
|
+
* Current is actual metadata only: an unavailable current effort is described,
|
|
54
|
+
* never silently replaced by an invented selectable capability.
|
|
55
|
+
*/
|
|
56
|
+
export function connectionOptions(kind, { discovery = {}, metadata = {}, filter = '' } = {}) {
|
|
57
|
+
if (kind === 'models') kind = 'model';
|
|
58
|
+
let title, description, items, current;
|
|
59
|
+
if (kind === 'login') {
|
|
60
|
+
title = t('Sign in to a model provider');
|
|
61
|
+
description = t('Choose how to authenticate. Your Comal account for Artifacts is separate.');
|
|
62
|
+
items = authItems(discovery);
|
|
63
|
+
// Being active is independent of being authenticated; do not mark a login
|
|
64
|
+
// as a no-op merely because that provider is already selected.
|
|
65
|
+
current = id(metadata.authProvider);
|
|
66
|
+
} else if (kind === 'provider') {
|
|
67
|
+
title = t('Choose a provider');
|
|
68
|
+
description = t('Choose a workspace profile or login provider. The connection is checked before replacing the active provider.');
|
|
69
|
+
items = providerItems(discovery);
|
|
70
|
+
current = id(metadata.profile);
|
|
71
|
+
if (!current) {
|
|
72
|
+
const auth = id(metadata.authProvider);
|
|
73
|
+
if (items.some(item => item.id === auth && item.kind === 'auth')) current = auth;
|
|
74
|
+
}
|
|
75
|
+
} else if (kind === 'model') {
|
|
76
|
+
title = t('Choose a model');
|
|
77
|
+
description = t('Provider: {provider}. The selected model applies to the next call; active work keeps its model.', { provider: text(metadata.profile || metadata.authProvider || metadata.provider, 100) || 'openai' });
|
|
78
|
+
current = id(metadata.model);
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
items = list(discovery.models).flatMap(value => {
|
|
81
|
+
const model = typeof value === 'string' ? { id: value } : value;
|
|
82
|
+
const key = id(model?.id);
|
|
83
|
+
if (!key || seen.has(key)) return [];
|
|
84
|
+
seen.add(key);
|
|
85
|
+
const limit = Number.isSafeInteger(model.inputTokenLimit) && model.inputTokenLimit > 0 ? t('{count} input tokens', { count: model.inputTokenLimit }) : '';
|
|
86
|
+
return [{ id: key, label: key, description: join([text(model.display_name || model.displayName), limit]), kind: 'model' }];
|
|
87
|
+
});
|
|
88
|
+
} else if (kind === 'effort') {
|
|
89
|
+
title = t('Choose effort');
|
|
90
|
+
const report = discovery.capabilities || metadata.modelCapabilities;
|
|
91
|
+
const capability = report?.effort;
|
|
92
|
+
const stale = report?.model && metadata.model && report.model !== metadata.model;
|
|
93
|
+
const supported = stale ? null : capability?.supported;
|
|
94
|
+
// Explicit unsupported/stale metadata outranks an older autocomplete list.
|
|
95
|
+
const announced = stale || supported === false ? [] : capability?.levels ?? discovery.effortLevels;
|
|
96
|
+
const levels = new Set(list(announced));
|
|
97
|
+
const selectable = EFFORT_LEVELS.filter(level => level === 'auto' || levels.has(level));
|
|
98
|
+
current = EFFORT_LEVELS.includes(metadata.effort) ? metadata.effort : null;
|
|
99
|
+
description = supported === false ? t('This model does not announce configurable effort. Auto uses the provider default.')
|
|
100
|
+
: !stale && (supported === true || selectable.length > 1) ? t('Only levels announced by the active model are offered. Auto uses the provider default.')
|
|
101
|
+
: t('Effort support is unknown. Only auto is available; use /capabilities refresh to check.');
|
|
102
|
+
if (current && !selectable.includes(current)) description += ' ' + t('Current effort {effort} is not in the announced levels.', { effort: current });
|
|
103
|
+
if (!current) description += ' ' + t('Current effort is unknown. Auto is the safe default.');
|
|
104
|
+
items = selectable.map(level => ({ id: level, label: level, description: level === 'auto' ? t('Provider default; no explicit effort is sent.') : t('Announced by the active model'), kind: 'effort' }));
|
|
105
|
+
} else throw new TypeError('Unknown connection selector.');
|
|
106
|
+
|
|
107
|
+
const term = text(filter, 256).toLocaleLowerCase();
|
|
108
|
+
items = unique(items).map(item => ({ ...item, current: item.id === current, noOp: kind !== 'login' && item.id === current, ...(item.id === current ? { badge: t('Current') } : {}) }))
|
|
109
|
+
.filter(item => !term || `${item.id} ${item.label} ${item.description}`.toLocaleLowerCase().includes(term));
|
|
110
|
+
return { title, description, items, selected: items.some(item => item.id === current) ? current : undefined, command: kind, current };
|
|
111
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// UI projection only. Admission, persistence, authentication and actual provider
|
|
2
|
+
// replacement remain the responsibility of the queue and local control boundary.
|
|
3
|
+
const COMMANDS = new Set(['model', 'provider', 'effort', 'login', 'logout']);
|
|
4
|
+
const CHANGES = new Set(['model', 'provider', 'effort']);
|
|
5
|
+
const TERMINAL = new Set(['applied', 'failed', 'cancelled']);
|
|
6
|
+
const REASONS = new Set(['cancelled', 'timeout', 'authentication', 'unavailable', 'unsupported', 'unknown', 'superseded']);
|
|
7
|
+
const KEYS = ['provider', 'authProvider', 'profile', 'model', 'effort'];
|
|
8
|
+
const MESSAGES = Object.freeze({
|
|
9
|
+
pending: 'Connection change queued. The active connection stays in use.',
|
|
10
|
+
applying: 'Checking the selected connection before applying it.',
|
|
11
|
+
applied: 'Connection change applied.',
|
|
12
|
+
cancelled: 'Connection change cancelled.',
|
|
13
|
+
failed: 'Connection change failed. The previous connection is still active.',
|
|
14
|
+
uncertain: 'Connection change could not be confirmed. Check the active connection before trying again.',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function label(value) {
|
|
18
|
+
if (typeof value !== 'string') return '';
|
|
19
|
+
// Never render terminal escapes or URL credentials/query strings in a status.
|
|
20
|
+
let text = value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
21
|
+
text = text.replace(/https?:\/\/[^\s]+/gi, '[endpoint]');
|
|
22
|
+
return [...text].slice(0, 120).join('');
|
|
23
|
+
}
|
|
24
|
+
function selection(value = {}) {
|
|
25
|
+
return Object.fromEntries(KEYS.filter(key => typeof value[key] === 'string').map(key => [key, label(value[key])]));
|
|
26
|
+
}
|
|
27
|
+
function action(value) {
|
|
28
|
+
if (!value || !COMMANDS.has(value.command) || typeof value.argument !== 'string') throw new TypeError('Invalid connection action.');
|
|
29
|
+
const argument = value.argument.trim();
|
|
30
|
+
if (!argument || argument.length > 512 || /[\x00-\x1f\x7f-\x9f]/.test(argument)) throw new TypeError('Invalid connection argument.');
|
|
31
|
+
if (['model', 'effort'].includes(value.command) && /\s/.test(argument)) throw new TypeError('Connection selection must be one identifier.');
|
|
32
|
+
if (['login', 'logout'].includes(value.command) && !/^[a-z][a-z0-9_-]*(?: --api-key-env [A-Za-z_][A-Za-z0-9_]*)?$/.test(argument)) throw new TypeError('Invalid credential control action.');
|
|
33
|
+
return {command: value.command, argument};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function connectionAction(text) {
|
|
37
|
+
if (typeof text !== 'string') return null;
|
|
38
|
+
const match = /^\/(model|provider|effort|login|logout)\s+([^\r\n]+)$/.exec(text.trim());
|
|
39
|
+
if (!match || match[1]==='login' && match[2] === 'status') return null;
|
|
40
|
+
try { return action({command: match[1], argument: match[2]}); } catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Intentionally ignores error.message, stack, HTTP response bodies and URLs.
|
|
44
|
+
export function connectionFailureReason(error) {
|
|
45
|
+
if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return 'cancelled';
|
|
46
|
+
if (error?.name === 'TimeoutError' || ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'MODEL_TIMEOUT'].includes(error?.code)) return 'timeout';
|
|
47
|
+
if ([401, 403].includes(error?.status) || ['AUTH_REQUIRED', 'UNAUTHENTICATED','PROVIDER_AUTH_REQUIRED','CONNECTION_AUTH_REQUIRED'].includes(error?.code)) return 'authentication';
|
|
48
|
+
if(Number.isInteger(error?.status) && error.status>=500)return 'unavailable';
|
|
49
|
+
if (['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'EAI_AGAIN'].includes(error?.code)) return 'unavailable';
|
|
50
|
+
if (['MODEL_CONTROL_UNSUPPORTED', 'MODEL_EFFORT_UNAVAILABLE', 'UNSUPPORTED_EFFORT'].includes(error?.code)) return 'unsupported';
|
|
51
|
+
return 'unknown';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ConnectionTransitions {
|
|
55
|
+
constructor(active = {}, {limit = 64} = {}) {
|
|
56
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw new TypeError('Invalid connection history limit.');
|
|
57
|
+
this.active = selection(active); this.limit = limit; this.records = new Map();
|
|
58
|
+
this.sequence = 0; this.latestApplied = 0;
|
|
59
|
+
}
|
|
60
|
+
admit(id, requested, {queued = true} = {}) {
|
|
61
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(id) || this.records.has(id)) throw new TypeError('Connection action ID must be new.');
|
|
62
|
+
const intent = action(requested);
|
|
63
|
+
// Keep unfinished actions: history pressure must never hide queued controls.
|
|
64
|
+
if (this.records.size >= this.limit) {
|
|
65
|
+
const oldest = [...this.records.values()].find(record => TERMINAL.has(record.state));
|
|
66
|
+
if (!oldest) throw new Error('Connection action history is full.');
|
|
67
|
+
this.records.delete(oldest.id);
|
|
68
|
+
}
|
|
69
|
+
this.records.set(id, {id, intent, queued: Boolean(queued), state: 'pending', attempt: null, sequence: 0, revision: 0, unchanged: false, reason: null});
|
|
70
|
+
return this.get(id);
|
|
71
|
+
}
|
|
72
|
+
revisePending(id, requested) {
|
|
73
|
+
const record = this.records.get(id);
|
|
74
|
+
if (!record || record.state !== 'pending') return false;
|
|
75
|
+
const intent = action(requested);
|
|
76
|
+
if (record.intent.command === intent.command && record.intent.argument === intent.argument) return false;
|
|
77
|
+
record.intent = intent; record.revision++;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
start(id) {
|
|
81
|
+
const record = this.records.get(id);
|
|
82
|
+
if (!record || record.state !== 'pending') return null;
|
|
83
|
+
record.state = 'applying'; record.attempt = ++this.sequence; record.sequence = record.attempt;
|
|
84
|
+
return record.attempt;
|
|
85
|
+
}
|
|
86
|
+
complete(id, attempt, {outcome, active, unchanged = false, reason, error} = {}) {
|
|
87
|
+
const record = this.records.get(id);
|
|
88
|
+
if (!record || record.state !== 'applying' || record.attempt !== attempt) return false;
|
|
89
|
+
if (!['applied', 'failed', 'cancelled'].includes(outcome)) throw new TypeError('Explicit connection outcome required.');
|
|
90
|
+
// A late result may close its card but cannot roll back a newer application.
|
|
91
|
+
if (attempt < this.latestApplied) {
|
|
92
|
+
record.state = 'cancelled'; record.reason = 'superseded'; record.unchanged = false;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (outcome === 'applied') {
|
|
96
|
+
if (!active || typeof active !== 'object') throw new TypeError('Applied connection requires authoritative metadata.');
|
|
97
|
+
this.active = selection(active); this.latestApplied = attempt;
|
|
98
|
+
}
|
|
99
|
+
record.state = outcome; record.unchanged = outcome !== 'applied' && unchanged === true;
|
|
100
|
+
record.reason = outcome === 'applied' ? null : REASONS.has(reason) ? reason : connectionFailureReason(error);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
cancel(id) {
|
|
104
|
+
const record = this.records.get(id);
|
|
105
|
+
// Removing a queued item is certain. An in-flight login/provider operation
|
|
106
|
+
// must settle at its actual control boundary, never by optimistic UI cancel.
|
|
107
|
+
if (!record || record.state !== 'pending') return false;
|
|
108
|
+
record.state = 'cancelled'; record.reason = 'cancelled'; record.unchanged = true;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
canRetry(id) {
|
|
112
|
+
const record = this.records.get(id);
|
|
113
|
+
return Boolean(record?.state === 'failed' && record.queued && record.unchanged && CHANGES.has(record.intent.command));
|
|
114
|
+
}
|
|
115
|
+
retry(id, newId, {confirmed = false} = {}) {
|
|
116
|
+
if (confirmed !== true || !this.canRetry(id)) return null;
|
|
117
|
+
const previous = this.records.get(id), intent = {...previous.intent};
|
|
118
|
+
this.admit(newId, intent); this.records.get(newId).retryOf = id;
|
|
119
|
+
// This is a new admission proposal, not execution or a queue mutation.
|
|
120
|
+
return {...intent, text: `/${intent.command} ${intent.argument}`, retryOf: id, id: newId};
|
|
121
|
+
}
|
|
122
|
+
get(id) {
|
|
123
|
+
const record = this.records.get(id); if (!record) return null;
|
|
124
|
+
const uncertain = ['failed', 'cancelled'].includes(record.state) && !record.unchanged && record.reason !== 'superseded';
|
|
125
|
+
return {id: record.id, revision: record.revision, command: record.intent.command, target: label(record.intent.argument), state: record.state,
|
|
126
|
+
message: MESSAGES[uncertain ? 'uncertain' : record.state], reason: record.reason,
|
|
127
|
+
confirmedUnchanged: record.unchanged, retryable: this.canRetry(id),
|
|
128
|
+
...(record.retryOf ? {retryOf: record.retryOf} : {})};
|
|
129
|
+
}
|
|
130
|
+
snapshot() {
|
|
131
|
+
const changes = [...this.records.keys()].map(id => this.get(id));
|
|
132
|
+
return {active: {...this.active}, pending: changes.filter(item => ['pending', 'applying'].includes(item.state)), changes, latest: changes.at(-1) || null};
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/tui/controller.mjs
CHANGED
|
@@ -9,6 +9,9 @@ import { shellCommands } from '../auto-guard.mjs';
|
|
|
9
9
|
import { findImageCandidates } from '../image-attachments.mjs';
|
|
10
10
|
import { Composer } from './composer.mjs';
|
|
11
11
|
import { isRemoteCommand } from '../remote-commands.mjs';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { connectionOptions } from './connection-options.mjs';
|
|
14
|
+
import { ConnectionTransitions, connectionAction, connectionFailureReason } from './connection-transition.mjs';
|
|
12
15
|
|
|
13
16
|
const IMMEDIATE = new Set(['cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider', 'exit', 'quit']);
|
|
14
17
|
|
|
@@ -28,12 +31,25 @@ export class InteractiveController {
|
|
|
28
31
|
attachSession(session) {
|
|
29
32
|
this.session = session; this.ui.loadSession(session); this.ui.updateMetadata(this.backend.metadata());
|
|
30
33
|
const id = session.id;
|
|
34
|
+
this.connections=new ConnectionTransitions(this.backend.metadata());this.connectionItems=new Map();
|
|
31
35
|
this.queue = new MessageQueue(session.queue, snapshot => this.backend.saveQueue(id, snapshot));
|
|
32
36
|
this.runner = new QueueRunner(this.queue, async item => {
|
|
33
37
|
if(item.remote){await this.backend.validateRemote(item);if(item.text.trimStart().startsWith('/')&&!isRemoteCommand(item.text))throw new Error('This command is only available in the local terminal.');}
|
|
34
38
|
this.ui.setBusy(true);
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
const change=this.connectionItems.get(item.id),attempt=change?this.connections.start(change.id):null,before=this.connectionIdentity();
|
|
40
|
+
this.syncConnectionStatus();
|
|
41
|
+
try {
|
|
42
|
+
const result=await this.backend.execute(item.text,{userIntent:item.text,attachments:item.attachments || [],shouldYield:()=>this.queue.items.length>0});
|
|
43
|
+
if(change && attempt!==null){
|
|
44
|
+
const failed=Boolean(result),unchanged=before===this.connectionIdentity() && ['model','provider','effort'].includes(change.action.command);
|
|
45
|
+
this.connections.complete(change.id,attempt,{outcome:failed?'failed':'applied',active:this.backend.metadata(),unchanged});
|
|
46
|
+
if(failed)this.scheduleConnectionRecovery(change.id,change.action);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}catch(error){
|
|
50
|
+
if(change && attempt!==null){this.connections.complete(change.id,attempt,{outcome:connectionFailureReason(error)==='cancelled'?'cancelled':'failed',active:this.backend.metadata(),unchanged:before===this.connectionIdentity() && ['model','provider','effort'].includes(change.action.command),error});this.scheduleConnectionRecovery(change.id,change.action);}
|
|
51
|
+
throw error;
|
|
52
|
+
}finally {this.ui.setBusy(false);this.ui.updateMetadata(this.backend.metadata());this.syncConnectionStatus();}
|
|
37
53
|
}, { blocked: () => Boolean(this.peerProcessing || this.ui.selecting || this.modalOverlay() || this.ui.editingQueue || this.switching || this.closed), onChange: () => { this.syncQueue(); this.refreshDiscovery(); }, onError: error => this.ui.notice(error.message, true) });
|
|
38
54
|
this.syncQueue(); this.refreshDiscovery();
|
|
39
55
|
if (this.queue.items.length || this.queue.interrupted.length) this.ui.notice(t("The recovered queue is paused. Check F4 before resuming; interrupted messages were not resent."));
|
|
@@ -47,7 +63,7 @@ export class InteractiveController {
|
|
|
47
63
|
finally{this.ui.setBusy(false);}
|
|
48
64
|
})().catch(error=>this.ui.notice(error.message,true)).finally(()=>{this.peerProcessing=null;this.runner.kick();});
|
|
49
65
|
}
|
|
50
|
-
syncQueue() { this.ui.setQueue(this.queue.snapshot()); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.min(this.ui.overlay.index, Math.max(0, this.queue.items.length - 1)); }
|
|
66
|
+
syncQueue() { this.syncConnectionQueue();this.ui.setQueue(this.queue.snapshot()); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.min(this.ui.overlay.index, Math.max(0, this.queue.items.length - 1)); }
|
|
51
67
|
scheduleDraft(text, attachments = this.ui.composer.attachments || []) {
|
|
52
68
|
clearTimeout(this.draftTimer); this.pendingDraft = { id: this.session.id, text, attachments };
|
|
53
69
|
this.draftTimer = setTimeout(() => { this.flushDraft().catch(e => this.ui.notice(e.message, true)); }, 500);
|
|
@@ -155,7 +171,7 @@ export class InteractiveController {
|
|
|
155
171
|
}
|
|
156
172
|
if (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()))await this.requireIdle();
|
|
157
173
|
if (command === 'help') { this.ui.showHelp(); return; }
|
|
158
|
-
if (command==='models' ||
|
|
174
|
+
if (command==='models' || ['model','login','provider','effort'].includes(command) && !arg.trim()) return this.chooseConnection(command,arg);
|
|
159
175
|
if (command === 'sessions' || (command === 'resume' && !arg)) { this.ui.showSessions(await this.backend.listSessions()); return; }
|
|
160
176
|
if (command === 'resume' || command === 'clear') {
|
|
161
177
|
await this.requireIdle(); this.switching = true;
|
|
@@ -174,6 +190,7 @@ export class InteractiveController {
|
|
|
174
190
|
}
|
|
175
191
|
if(command==='work'&&(arg.trim()==='recover'||arg.trim().startsWith('review '))){await this.requireIdle();const output=await this.backend.control(command,arg);if(output)this.ui.notice(output);return;}
|
|
176
192
|
if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
|
|
193
|
+
if(connectionAction(`/${command} ${arg}`)){await this.requireIdle();return this.connectionControl(command,arg);}
|
|
177
194
|
if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
|
|
178
195
|
if (command === 'table') { if (!arg) throw new Error(t("Use /table file.csv|tsv|json|md.")); const table = await this.backend.readTable(arg); this.ui.addTable(table.text, table.format, arg); return; }
|
|
179
196
|
const changing=(['cloud','model','effort','provider','login','logout'].includes(command) && (arg.trim() || ['login','logout'].includes(command))) || (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()));
|
|
@@ -184,42 +201,83 @@ export class InteractiveController {
|
|
|
184
201
|
} finally {if(changing){this.switching=false;this.runner.kick();}}
|
|
185
202
|
|
|
186
203
|
}
|
|
204
|
+
connectionIdentity() {
|
|
205
|
+
const meta=this.backend.metadata();return JSON.stringify(['provider','authProvider','profile','model','effort'].map(key=>meta[key] || null));
|
|
206
|
+
}
|
|
207
|
+
syncConnectionStatus() {if(this.connections)this.ui.setConnectionStatus?.(this.connections.snapshot());}
|
|
208
|
+
syncConnectionQueue() {
|
|
209
|
+
if(!this.connections || !this.queue)return;
|
|
210
|
+
const live=new Set([...this.queue.items,this.queue.running].filter(Boolean).map(item=>item.id));
|
|
211
|
+
for(const item of this.queue.items){
|
|
212
|
+
const action=connectionAction(item.text),old=this.connectionItems.get(item.id);
|
|
213
|
+
if(old?.text===item.text)continue;
|
|
214
|
+
if(action){
|
|
215
|
+
if(old && this.connections.get(old.id)?.state==='pending'){this.connections.revisePending(old.id,action);this.connectionItems.set(item.id,{...old,text:item.text,action});}
|
|
216
|
+
else {const id=old?randomUUID():item.id;this.connections.admit(id,action);this.connectionItems.set(item.id,{id,text:item.text,action});}
|
|
217
|
+
}else if(old){this.connections.cancel(old.id);this.connectionItems.set(item.id,{...old,text:item.text});}
|
|
218
|
+
}
|
|
219
|
+
for(const [queueId,change]of this.connectionItems)if(!live.has(queueId)){this.connections.cancel(change.id);this.connectionItems.delete(queueId);}
|
|
220
|
+
this.syncConnectionStatus();
|
|
221
|
+
}
|
|
222
|
+
async connectionControl(command,argument){
|
|
223
|
+
const requested=connectionAction(`/${command} ${argument}`),id=randomUUID(),before=this.connectionIdentity();
|
|
224
|
+
this.connections.admit(id,requested);const attempt=this.connections.start(id);this.switching=true;this.syncConnectionStatus();
|
|
225
|
+
let failure;
|
|
226
|
+
try{
|
|
227
|
+
const output=await this.backend.control(command,argument);if(output)this.ui.notice(output);
|
|
228
|
+
this.connections.complete(id,attempt,{outcome:'applied',active:this.backend.metadata()});
|
|
229
|
+
try{await this.backend.refreshConnections?.();}catch{}
|
|
230
|
+
}catch(error){
|
|
231
|
+
const reason=connectionFailureReason(error);this.connections.complete(id,attempt,{outcome:reason==='cancelled'?'cancelled':'failed',active:this.backend.metadata(),unchanged:before===this.connectionIdentity() && ['model','provider','effort'].includes(command),error});failure=error;
|
|
232
|
+
}finally{this.switching=false;this.ui.updateMetadata(this.backend.metadata());this.refreshDiscovery();this.syncConnectionStatus();this.runner.kick();}
|
|
233
|
+
if(failure){const state=this.connections.get(id);this.ui.notice(t(state.message),true);await this.recoverConnection(id,requested);}
|
|
234
|
+
}
|
|
235
|
+
scheduleConnectionRecovery(id,action){
|
|
236
|
+
setImmediate(()=>this.recoverConnection(id,action).catch(()=>{}));
|
|
237
|
+
}
|
|
238
|
+
async recoverConnection(id,action){
|
|
239
|
+
if(this.closed || this.ui.busy || this.runner.processing || this.modalOverlay() || this.ui.permission)return;
|
|
240
|
+
const state=this.connections.get(id);if(state?.state!=='failed')return;
|
|
241
|
+
const items=[{id:'keep',label:t('Keep the active connection'),description:t('Return to your draft without changing the connection.')}];
|
|
242
|
+
if(state.retryable)items.push({id:'retry',label:t('Retry this selection'),description:`/${action.command} ${state.target}`});
|
|
243
|
+
const discovery=this.backend.discovery?.() || {},profile=discovery.profiles?.find(p=>p.name===action.argument);
|
|
244
|
+
const loginProvider=profile?.authProvider || (discovery.authProviders?.some(p=>p.id===action.argument)?action.argument:null);
|
|
245
|
+
if(state.reason==='authentication' && action.command==='provider' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
|
|
246
|
+
items.push({id:'choose',label:t('Choose another connection'),description:t('Open the provider and workspace profile selector.')});
|
|
247
|
+
const reason={authentication:'Sign in or check the configured credential environment variable.',timeout:'The model server timed out.',unavailable:'The model server could not be reached.',unsupported:'The active model does not support this setting.',unknown:'The change could not be verified.'}[state.reason];
|
|
248
|
+
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});
|
|
249
|
+
if(this.closed)return;
|
|
250
|
+
if(choice==='retry' && this.connections.canRetry(id))await this.submit(`/${action.command} ${action.argument}`);
|
|
251
|
+
else if(choice==='login')await this.submit(`/login ${loginProvider}`);
|
|
252
|
+
else if(choice==='choose')await this.chooseConnection('provider');
|
|
253
|
+
this.runner.kick();
|
|
254
|
+
}
|
|
187
255
|
async chooseConnection(command, filter='') {
|
|
188
256
|
if(this.modalOverlay() || this.ui.permission)return;
|
|
189
|
-
const identity=()=>JSON.stringify([this.session.id
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
let
|
|
193
|
-
if(
|
|
194
|
-
const providers=this.backend.discovery?.().authProviders || [];
|
|
195
|
-
items=providers.map(p=>({id:p.id,label:p.label || p.id,description:`${p.id} · ${p.description || ''}`}));
|
|
196
|
-
title=t('Sign in to a model provider');description=t('Choose how to authenticate. Your Comal account for Artifacts is separate.');
|
|
197
|
-
}else{
|
|
257
|
+
const identity=()=>JSON.stringify([this.session.id,this.connectionIdentity()]),original=identity(),abort=new AbortController();
|
|
258
|
+
try{
|
|
259
|
+
await this.backend.refreshConnections?.();
|
|
260
|
+
let discovery=this.backend.discovery?.() || {};
|
|
261
|
+
if(['model','models'].includes(command)){
|
|
198
262
|
this.ui.setupStatus({title:t('Finding models'),description:t('Reading the current provider catalog. No task is sent. Esc cancels.')});
|
|
199
|
-
const previous=this.ui.callbacks.onSetupCancel;
|
|
200
|
-
this.ui.callbacks.onSetupCancel=()=>abort.abort();
|
|
263
|
+
const state=this.ui.setup,previous=this.ui.callbacks.onSetupCancel;this.ui.callbacks.onSetupCancel=()=>abort.abort();
|
|
201
264
|
let models;
|
|
202
|
-
try
|
|
203
|
-
finally
|
|
265
|
+
try{models=await this.backend.listModels({signal:abort.signal});}
|
|
266
|
+
finally{this.ui.callbacks.onSetupCancel=previous;if(this.ui.setup===state)this.ui.clearSetup();}
|
|
204
267
|
if(abort.signal.aborted || this.closed || identity()!==original)return;
|
|
205
|
-
this.refreshDiscovery();
|
|
206
|
-
const meta=this.backend.metadata();current=meta.model;
|
|
207
|
-
items=models.filter(item=>!filter || item.id.toLowerCase().includes(filter.trim().toLowerCase())).map(m=>({id:m.id,label:m.id,badge:m.id===current?t('Current'):'',description:[m.display_name || m.displayName,m.inputTokenLimit?`${m.inputTokenLimit} input tokens`:null].filter(Boolean).join(' · ')}));
|
|
208
|
-
title=t('Choose a model');description=t('Provider: {provider}. The selected model applies to the next call; active work keeps its model.',{provider:meta.authProvider || meta.profile || meta.provider || 'openai'});
|
|
268
|
+
this.refreshDiscovery();discovery={...(this.backend.discovery?.() || {}),models};
|
|
209
269
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
selected=await this.ui.setupChoice({
|
|
270
|
+
const menu=connectionOptions(command,{discovery,metadata:this.backend.metadata(),filter});
|
|
271
|
+
if(!menu.items.length){this.ui.notice(t('No options found. Check the provider or use /model ID.'),true);return;}
|
|
272
|
+
const selected=await this.ui.setupChoice({...menu,searchable:true,signal:abort.signal});
|
|
213
273
|
if(!selected || this.closed)return;
|
|
214
274
|
if(identity()!==original)throw new Error(t('The provider changed. Open the model list again.'));
|
|
215
|
-
if(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if(this.ui.busy)this.ui.notice(t('Selection queued. It will apply after the current turn.'));
|
|
220
|
-
} catch(error) {if(!abort.signal.aborted)throw error;}
|
|
221
|
-
finally {this.runner.kick();}
|
|
275
|
+
if(menu.items.find(item=>item.id===selected)?.noOp)return;
|
|
276
|
+
await this.submit(`/${menu.command} ${selected}`);
|
|
277
|
+
}catch(error){if(!abort.signal.aborted)throw error;}
|
|
278
|
+
finally{this.runner.kick();}
|
|
222
279
|
}
|
|
280
|
+
|
|
223
281
|
async action(name, arg) {
|
|
224
282
|
if (name === 'exit') return this.exit();
|
|
225
283
|
if (name === 'interrupt') { this.backend.interrupt(); await this.queue.setPaused(true); this.syncQueue(); return; }
|
package/src/tui/screen.mjs
CHANGED
|
@@ -179,6 +179,15 @@ export class TerminalScreen {
|
|
|
179
179
|
motionEnabled() { return brandMotionEnabled({ env: this.env, stream: this.output }); }
|
|
180
180
|
brandFrame() { return Math.floor((Date.now() - this.brandStarted) / BRAND_FRAME_INTERVAL_MS); }
|
|
181
181
|
spinner() { return SPINNER[(this.motionEnabled() ? this.frame : 0) % SPINNER.length]; }
|
|
182
|
+
setConnectionStatus(snapshot) { this.connectionStatus=structuredClone(snapshot);this.schedule(); }
|
|
183
|
+
connectionHint() {
|
|
184
|
+
const snapshot=this.connectionStatus;if(!snapshot)return '';
|
|
185
|
+
const pending=snapshot.pending?.find(item=>item.state==='applying') || snapshot.pending?.[0];
|
|
186
|
+
if(pending)return t(pending.state==='applying'?'Checking /{command} {target} · active: {model}':'Queued /{command} {target} · active: {model}',{command:pending.command,target:pending.target,model:safeText(this.metadata.model || '?')}) + (snapshot.pending.length>1?` · +${snapshot.pending.length-1}`:'');
|
|
187
|
+
const latest=snapshot.latest;
|
|
188
|
+
if(latest?.state==='failed')return t(latest.message);
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
182
191
|
setDiscovery(context = {}) { this.discovery = structuredClone(context); this.schedule(); }
|
|
183
192
|
discoveryContext() { return { ...this.discovery, effortLevels: this.metadata.modelCapabilities?.effort?.levels || this.discovery.effortLevels, busy: this.busy, queue: this.queue, mode: this.mode, selecting: this.selecting, editingQueue: this.editingQueue }; }
|
|
184
193
|
refreshDiscovery() { this.callbacks.onDiscover?.(); }
|
|
@@ -661,7 +670,7 @@ export class TerminalScreen {
|
|
|
661
670
|
const footer = [clip(bar, columns), tone.muted('─'.repeat(columns)), composerHeading];
|
|
662
671
|
const offset = Math.max(0, composer.cursorRow - composerHeight + 1);
|
|
663
672
|
const inputLines = Array.from({ length: composerHeight }, (_, i) => tone.muted(i === 0 ? ' ❯ ' : ' │ ') + (composer.lines[offset + i] || ''));
|
|
664
|
-
const hints = this.selecting ? tone.warning(t(" SELECTION · Drag and copy from your terminal · F5/Esc returns")) : this.attachmentStatus ? (['unsupported','invalid'].includes(this.attachmentStatus.state) ? tone.warning : tone.muted)(' ' + safeText(this.attachmentStatus.message)) : tone.muted(' ' + (completions.items.length ? t("Tab or Enter completes · ↑/↓ chooses · Esc closes · does not send") : (this.overlay?.kind === 'help' ? t("Help open · composer active · PgUp/PgDn scrolls · Esc/F1 closes") : this.preparedDraft ? t("Action prepared · Esc recovers your draft · Enter sends and restores it") : contextualTip(this.composer.text, this.discoveryContext()))));
|
|
673
|
+
const hints = this.selecting ? tone.warning(t(" SELECTION · Drag and copy from your terminal · F5/Esc returns")) : this.attachmentStatus ? (['unsupported','invalid'].includes(this.attachmentStatus.state) ? tone.warning : tone.muted)(' ' + safeText(this.attachmentStatus.message)) : tone.muted(' ' + (completions.items.length ? t("Tab or Enter completes · ↑/↓ chooses · Esc closes · does not send") : (this.overlay?.kind === 'help' ? t("Help open · composer active · PgUp/PgDn scrolls · Esc/F1 closes") : this.preparedDraft ? t("Action prepared · Esc recovers your draft · Enter sends and restores it") : (this.connectionHint() || contextualTip(this.composer.text, this.discoveryContext())))));
|
|
665
674
|
const suggestionLines = [];
|
|
666
675
|
if (completionHeight) {
|
|
667
676
|
suggestionLines.push(tone.muted(t(" Suggestions · {v0} · Tab completes", {v0: completions.items.length})));
|